From a61346f453369257bd6f69b45ef4b87092a452ba Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 16:59:37 +0800
Subject: [PATCH 01/76] refactor(models): remove unsupported Wan2 V2V adapter
---
.agents/knowledge/architecture.md | 1 -
.../knowledge/topics/adapter_conventions.md | 8 +-
.agents/knowledge/topics/sample_lifecycle.md | 2 +-
AGENTS.md | 2 +-
README.md | 11 -
examples/grpo/full/wan21/i2v.yaml | 2 +-
examples/grpo/full/wan21/t2v.yaml | 2 +-
examples/grpo/full/wan22/i2v.yaml | 2 +-
examples/grpo/full/wan22/t2v.yaml | 2 +-
examples/grpo/lora/wan21/i2v.yaml | 2 +-
examples/grpo/lora/wan21/t2v.yaml | 2 +-
examples/grpo/lora/wan21/v2v.yaml | 127 ----
examples/grpo/lora/wan22/i2v.yaml | 2 +-
examples/grpo/lora/wan22/t2v.yaml | 2 +-
examples/nft/full/wan22/t2v.yaml | 2 +-
examples/nft/lora/wan21/i2v.yaml | 2 +-
examples/nft/lora/wan21/t2v.yaml | 2 +-
examples/nft/lora/wan22/t2v.yaml | 2 +-
guidance/acceleration.md | 2 +-
multinode_examples/train.yaml | 2 +-
src/flow_factory/hparams/model_args.py | 1 -
src/flow_factory/models/latent_geometry.py | 2 +-
src/flow_factory/models/registry.py | 1 -
src/flow_factory/models/wan/wan2_v2v.py | 609 ------------------
tests/docs/test_sensenova_docs.py | 2 +-
tests/models/test_all_model_loaders.py | 3 -
tests/models/test_wan_registry.py | 31 +
27 files changed, 53 insertions(+), 775 deletions(-)
delete mode 100644 examples/grpo/lora/wan21/v2v.yaml
delete mode 100644 src/flow_factory/models/wan/wan2_v2v.py
create mode 100644 tests/models/test_wan_registry.py
diff --git a/.agents/knowledge/architecture.md b/.agents/knowledge/architecture.md
index e8fc5fb29..d8418b396 100644
--- a/.agents/knowledge/architecture.md
+++ b/.agents/knowledge/architecture.md
@@ -125,7 +125,6 @@ All four registries map string keys → lazy import paths. Resolution: registry
| `z-image` | `ZImageAdapter` | Text-to-Image |
| `wan2_t2v` | `Wan2_T2V_Adapter` | Text-to-Video |
| `wan2_i2v` | `Wan2_I2V_Adapter` | Image-to-Video |
-| `wan2_v2v` | `Wan2_V2V_Adapter` | Video-to-Video |
| `ltx2_t2av` | `LTX2_T2AV_Adapter` | Text-to-Audio-Video |
| `ltx2_i2av` | `LTX2_I2AV_Adapter` | Image-to-Audio-Video |
| `bagel` | `BagelAdapter` | Text-to-Image & Image(s)-to-Image (T2I & I2I both batched via NaViT packing; subset-round packing handles variable I2I reference-image count, no per-sample fallback — see `topics/adapter_conventions.md`) |
diff --git a/.agents/knowledge/topics/adapter_conventions.md b/.agents/knowledge/topics/adapter_conventions.md
index 04550ff57..fc55add0c 100644
--- a/.agents/knowledge/topics/adapter_conventions.md
+++ b/.agents/knowledge/topics/adapter_conventions.md
@@ -124,7 +124,7 @@ Defined in `models/abc.py` (`preprocessing_modules` / `inference_modules` proper
- `inference()` condition parameters (`images`, `videos`, `audios`) arrive as `MultiImageBatch` / `MultiVideoBatch` / `MultiAudioBatch` (nested batch, e.g. `List[List[Image.Image]]`, `List[List[Tensor]]`) from the training pipeline collator (`data_utils/dataset.py` `collate_fn`). Type annotations on `inference()` must use the multi-form, not the bare `ImageBatch` / `VideoBatch` / `AudioBatch`.
- **Multi-media batch homogeneity**: `_preprocess_batch` (`data_utils/dataset.py`) guarantees `List[List[Media]]` for every modality column — empty samples contribute `[]`, single-item samples contribute `[item]`, multi-item samples contribute `[item1, ..., itemN]`. This keeps HF Arrow columns homogeneous and lets every `encode_*` consume a single shape.
- **Image-column persistence (HF Image feature)**: the raw `images` column and any `encode_image` output listed in `python_format_columns` (ClassVar on `BaseAdapter`, empty by default) are stored via the HuggingFace `Image` feature (PNG bytes) instead of raw tensors, and **read back as PIL** (`List[List[PIL.Image]]`). This is what lets ragged multi-reference batches (variable size/count) serialize — raw tensors are only Arrow-serializable when uniform. Opt in per adapter only for genuine RGB images (e.g. Bagel and SenseNova `condition_images`); never declare preprocessed/non-RGB tensors (VAE-ready video tensors, latents) — PIL conversion is lossy and breaks tensor consumers (e.g. LTX2-I2AV `condition_images` stays a tensor). Consumers must normalize via `_standardize_image_input` / `standardize_image_batch` before any tensor op. To keep PIL on the **sample** too (not just the dataset cache), the adapter's `ImageConditionSample` subclass must set `condition_images_as_pil = True` (else `__post_init__` re-canonicalizes to `List[Tensor(C,H,W)]` [0,1]); e.g. `BagelI2ISample` and `SenseNovaI2ISample`.
-- Single-condition adapters must flatten internally via `_standardize_image_input` / `_standardize_video_input` using `is_multi_image_batch` / `is_multi_video_batch` to extract the first element per sample (e.g. `Wan2_I2V._standardize_image_input`, `Wan2_V2V._standardize_video_input`, `LTX2_I2AV._standardize_image_input`). Multi-condition adapters (e.g. `Flux2`, Bagel, and SenseNova) consume the nested structure directly.
+- Single-condition adapters must flatten internally via `_standardize_image_input` / `_standardize_video_input` using `is_multi_image_batch` / `is_multi_video_batch` to extract the first element per sample (e.g. `Wan2_I2V._standardize_image_input`, `LTX2_I2AV._standardize_image_input`). Multi-condition adapters (e.g. `Flux2`, Bagel, and SenseNova) consume the nested structure directly.
## Latent Geometry
@@ -150,13 +150,13 @@ Only axis roles are stored, never dynamic sizes (Seq/H/W/T). Packed models fold
### Per-adapter
-All 15 adapters resolve correctly via default ndim inference — none override `LATENT_AXES`:
+All 14 adapters resolve correctly via default ndim inference — none override `LATENT_AXES`:
| Adapter(s) | Layout |
|---|---|
| FLUX.1/Kontext/2/Klein, Qwen-Image/Edit-Plus, Bagel, LTX2 T2AV/I2AV | PACKED |
| SD3.5, Z-Image, SenseNova | CONV |
-| Wan2 T2V/I2V/V2V | VIDEO |
+| Wan2 T2V/I2V | VIDEO |
LTX2 packs `[video|audio]` into one `(B, Seq, C)` sequence, so it resolves as PACKED (the split point lives in the adapter's own `forward` via `video_seq_len`, not in the geometry layer). I2I/I2V/Edit store only the generated latent in `all_latents` (condition is concatenated inside `forward()` / kept in separate fields), so the standard layout applies and reference-image count is irrelevant. Override `LATENT_AXES` only for a genuinely non-standard rank/channel layout.
@@ -171,7 +171,7 @@ LTX2 packs `[video|audio]` into one `(B, Seq, C)` sequence, so it resolves as PA
7. **CFG two-stage consistency** — `encode_prompt()` and `forward()` must use the same threshold for CFG activation (`guidance_scale > 1.0`, or `> 0.0` for Z-Image). `forward()` must gracefully handle the case where `guidance_scale > threshold` but negative embeds are `None` (warn + fallback, never error). See "Classifier-Free Guidance (CFG) Convention" section above.
8. **Bagel batch handling (NaViT subset-round packing)** — Bagel uses sequence packing, not a leading batch dim. **Both T2I and I2I** pack all B samples into one block-diagonal forward (`_build_gen_context` + `_forward_packed`; the framework's `(B, num_tokens, dim)` latents reshape to packed `(B*num_tokens, dim)` and back). For I2I, reference images are added in per-image rounds (`num_rounds = max per-sample count`); a sample without an r-th image is passed as `None` to `prepare_vae_images` / `prepare_vit_images`, which keep its cached KV and add a **zero-length query segment**. So a **variable per-sample reference-image count** (and varying sizes) is handled by packing directly — there is no per-sample (`batch_size=1`) fallback. The cache merge requires every sample to remain on the key/value side, so only the query may be a subset. The prefill is `@torch.no_grad` and every round has >=1 active image (`max_seqlen_q > 1`), avoiding flash-attn zero-length pitfalls (no backward, no `max_seqlen_q==1`). `_is_i2i(condition_images)` depends only on condition-image presence (distributed-safe). CFG global renorm is computed **per sample** over `packed_seqlens - 2`, and `forward()` returns per-sample `(B,)` log-prob (not per-token). **Distributed**: the prefill makes a data-dependent number of `language_model` forward calls (`2*num_rounds + 2`); `language_model` is the only FSDP-sharded module (frozen ViT/VAE are unsharded, so they don't count). Under FSDP FULL_SHARD/HYBRID (and ZeRO-3) each call AllGathers `language_model`'s shard, so per-rank counts mismatch and deadlock — `_assert_variable_count_supported` fails fast there (`@torch.no_grad` does not help; FSDP still all-gathers to compute). DDP / DeepSpeed ZeRO-1/2 (the Bagel I2I backends) replicate params (local forward, fixed grad sync at backward), so variable counts are safe. The FSDP-safe alternative is to gather `language_model` once for the generation (`summon_full_params` / `reshard_after_forward=False`).
9. **Image columns persist via HF Image feature (variable-size/count I2I)** — preprocessing stores image data as PIL via the HF `Image` feature, not raw tensors; ragged tensor columns (multi-reference images of varying size/count) are NOT Arrow-serializable and otherwise crash in `Dataset.map` with `TypeError: a bytes-like object is required, not 'Tensor'` / `OverflowError`. The raw `images` column is always stored this way; an `encode_image` output is stored this way only when its name is listed in the adapter's `python_format_columns` ClassVar (default empty — opt in for RGB images only, e.g. Bagel and SenseNova `condition_images`). These columns **read back as PIL** (`List[List[PIL.Image]]`); the `torch` format excludes them (`_apply_torch_format` in `dataset.py`), and `collate_fn` keeps them as a `MultiImageBatch`. To keep PIL end-to-end on the **sample** (not just the cache), the adapter's `ImageConditionSample` subclass must also set `condition_images_as_pil=True` (else `ImageConditionSample.__post_init__` re-canonicalizes to `List[Tensor(C,H,W)]` [0,1]); e.g. `BagelI2ISample` and `SenseNovaI2ISample`. Bump `_PREPROCESS_FORMAT_VERSION` if the on-disk image format changes again.
-10. **Latent geometry override is rarely needed** — `resolve_latent_axes` infers axis roles from latent ndim (3=packed, 4=conv, 5=video), correct for all 15 adapters. Set the `LATENT_AXES` ClassVar only for a genuinely non-standard rank/channel layout. LTX2's packed `[video|audio]` resolves as PACKED; SenseNova's generated pixels resolve as CONV. Modality splits and conditioning remain adapter-owned and do not change the generated trajectory layout. See "Latent Geometry".
+10. **Latent geometry override is rarely needed** — `resolve_latent_axes` infers axis roles from latent ndim (3=packed, 4=conv, 5=video), correct for all 14 adapters. Set the `LATENT_AXES` ClassVar only for a genuinely non-standard rank/channel layout. LTX2's packed `[video|audio]` resolves as PACKED; SenseNova's generated pixels resolve as CONV. Modality splits and conditioning remain adapter-owned and do not change the generated trajectory layout. See "Latent Geometry".
11. **Diffusers cache readiness is explicit** — set `supports_diffusers_cache = True` only when every transformer forward branch, including CFG/STG variants and every transformer in a multi-transformer adapter, runs inside `cache_context`. The rollout accelerator rejects the default `False` before enabling any component. See `guidance/acceleration.md` "Model cache-readiness".
12. **LTX2 rollouts publish structured trajectories only** — `LTX2_T2AV_Adapter` / `LTX2_I2AV_Adapter` `inference()` fill `BaseSample.trajectory` with one `StructuredTrajectory` per sample (per-component states, full per-component schedules, joint + per-component log probabilities, and the latent-shaped callbacks in `LTX2_STRUCTURED_CALLBACK_FIELDS`) and leave every legacy field (`timesteps`, `all_latents`, `latent_index_map`, `log_probs`, `log_prob_index_map`) `None`. Non-latent callbacks (e.g. `std_dev_t`, `noise_level`) stay in `extra_kwargs` with their `callback_index_map`, which is present only when such a callback was actually collected. Trainers must read the trajectory through the adapter bridge (`get_terminal_state`, `get_replay_step`, `get_replay_callback`), never by indexing the legacy fields. I2AV additionally carries a video `active_mask` derived from `~conditioning_mask`, so the conditioning frame is excluded from every reduction, log-prob weighting and forward-process noising.
diff --git a/.agents/knowledge/topics/sample_lifecycle.md b/.agents/knowledge/topics/sample_lifecycle.md
index 8b0ae579d..c4e59c456 100644
--- a/.agents/knowledge/topics/sample_lifecycle.md
+++ b/.agents/knowledge/topics/sample_lifecycle.md
@@ -41,7 +41,7 @@ Three tiers, intentionally deviating from the strict ALL-YAML rule in `.cursor/r
| Tier | Models | YAML field | Rationale |
|------|--------|------------|-----------|
-| T1 | Wan video (T2V / I2V / V2V) + LTX2 (T2AV / I2AV) | explicit `true` | per-sample tensors are GB-scale; `sample()`/`optimize()` OOMs without offload |
+| T1 | Wan video (T2V / I2V) + LTX2 (T2AV / I2AV) | explicit `true` | per-sample tensors are GB-scale; `sample()`/`optimize()` OOMs without offload |
| T2 | Flux2 / Flux2-Klein / Qwen-Image-Edit-Plus (+ OPD SD3.5) | explicit `false` + multi-line pros/cons comment | moderate VRAM pressure; user-decision point with documentation co-located |
| T3 | FLUX1 / SD3 / Qwen-Image / Z-Image / DPO / template | not added; relies on code default `False` | low pressure; zero-migration cost |
diff --git a/AGENTS.md b/AGENTS.md
index 1c0f9e1e9..06cbb0fa3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@
Flow-Factory is a unified **online RL fine-tuning framework** for diffusion/flow-matching models. It provides a modular architecture where trainers, model adapters, and reward models are independently extensible via a registry-based plugin system.
- **Algorithms**: GRPO, GRPO-Guard, DPPO, DPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1
-- **Models**: FLUX.1 (+Kontext), FLUX.2 (+Klein), SD3.5, Qwen-Image (+Edit-Plus), Z-Image, Wan2 (T2V/I2V/V2V), LTX2 (T2AV/I2AV), Bagel, SenseNova-U1 (1.0/1.5; T2I + ordered multi-reference I2I)
+- **Models**: FLUX.1 (+Kontext), FLUX.2 (+Klein), SD3.5, Qwen-Image (+Edit-Plus), Z-Image, Wan2 (T2V/I2V), LTX2 (T2AV/I2AV), Bagel, SenseNova-U1 (1.0/1.5; T2I + ordered multi-reference I2I)
- **Rewards**: PickScore (+Rank), CLIP, CLAP, ImageBind, OCR, GenEval/GenEval2, HPSv2, VLM-Evaluate, rational-rewards, and custom rewards
- **Python**: >=3.10 | **PyTorch**: >=2.6.0 | **License**: Apache-2.0
diff --git a/README.md b/README.md
index 5633b9527..60f43ec0a 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,6 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
- [Dataset](#-dataset)
- [Text-to-Image & Text-to-Video](#text-to-image--text-to-video)
- [Image-to-Image & Image-to-Video](#image-to-image--image-to-video)
- - [Video-to-Video](#video-to-video)
- [Reward Model](#-reward-model)
- [Acknowledgements](#-acknowledgements)
@@ -86,9 +85,6 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
| Wan2.2-TI2V-5B | 5B | wan2_i2v |
| Wan2.2-I2V-A14B | A14B | wan2_i2v |
- | Video-to-Video | Wan2.1-T2V-1.3B | 1.3B | wan2_v2v |
- | Wan2.1-T2V-14B | 14B | wan2_v2v |
-
| Text-to-Audio-Video | LTX-2 | 19B | ltx2_t2av |
| LTX-2.3 | 22B | ltx2_t2av |
| Image-to-Audio-Video | LTX-2 | 19B | ltx2_i2av |
@@ -277,13 +273,6 @@ multiple conditioning images, use the `images` key with an ordered list of image
{"prompt": "An astronaut riding a horse on Mars.", "images": ["path/to/condition_image_2_1.png", "path/to/condition_image_2_2.png"]}
```
-## Video-to-Video
-
-```jsonl
-{"prompt": "A hill in a sunset.", "video": "path/to/video1.mp4"}
-{"prompt": "An astronaut riding a horse on Mars.", "videos": ["path/to/video2.mp4", "path/to/video3.mp4"]}
-```
-
# 💯 Reward Model
Flow-Factory provides a flexible reward model system that supports both built-in and custom reward models for reinforcement learning.
diff --git a/examples/grpo/full/wan21/i2v.yaml b/examples/grpo/full/wan21/i2v.yaml
index fbd3917fb..aa40e82ce 100644
--- a/examples/grpo/full/wan21/i2v.yaml
+++ b/examples/grpo/full/wan21/i2v.yaml
@@ -26,7 +26,7 @@ model:
target_components: 'transformer' # Options: transformer
target_modules: "default" # Options: all, default, or list of module names like ["to_k", "to_q", "to_v", "to_out.0"]
model_name_or_path: "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers" # Wan-AI/Wan2.1-I2V-14B-480P-Diffusers / Wan-AI/Wan2.1-I2V-14B-720P-Diffusers
- model_type: "wan2_i2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_i2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/grpo/full/wan21/t2v.yaml b/examples/grpo/full/wan21/t2v.yaml
index 5f04bb39c..8477dc93e 100644
--- a/examples/grpo/full/wan21/t2v.yaml
+++ b/examples/grpo/full/wan21/t2v.yaml
@@ -26,7 +26,7 @@ model:
target_components: 'transformer' # Options: transformer (Wan2.1 & Wan2.2), transformer_2 (Wan2.2)
target_modules: "default" # Options: all, default, or list of module names like ["to_k", "to_q", "to_v", "to_out.0"]
model_name_or_path: "Wan-AI/Wan2.1-T2V-14B-Diffusers" # Wan-AI/Wan2.1-T2V-14B-Diffusers / Wan-AI/Wan2.2-T2V-A14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/grpo/full/wan22/i2v.yaml b/examples/grpo/full/wan22/i2v.yaml
index 1678310de..1bd124296 100644
--- a/examples/grpo/full/wan22/i2v.yaml
+++ b/examples/grpo/full/wan22/i2v.yaml
@@ -34,7 +34,7 @@ model:
# Pay attention to the `boundary_ratio` that used in Wan2.2, before/after which different transformer is used and therefore needed for backward.
target_modules: "default"
model_name_or_path: "Wan-AI/Wan2.2-I2V-A14B-Diffusers" # Wan-AI/Wan2.2-TI2V-5B-Diffusers / Wan-AI/Wan2.2-I2V-A14B-Diffusers
- model_type: "wan2_i2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_i2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/grpo/full/wan22/t2v.yaml b/examples/grpo/full/wan22/t2v.yaml
index 5c289a8fd..20a1a97da 100644
--- a/examples/grpo/full/wan22/t2v.yaml
+++ b/examples/grpo/full/wan22/t2v.yaml
@@ -34,7 +34,7 @@ model:
# Pay attention to the `boundary_ratio` that used in Wan2.2, before/after which different transformer is used and therefore needed for backward.
target_modules: "default"
model_name_or_path: "Wan-AI/Wan2.2-T2V-A14B-Diffusers" # Wan-AI/Wan2.1-T2V-14B-Diffusers / Wan-AI/Wan2.2-T2V-A14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/grpo/lora/wan21/i2v.yaml b/examples/grpo/lora/wan21/i2v.yaml
index 2cdc41c72..304c4cbe6 100644
--- a/examples/grpo/lora/wan21/i2v.yaml
+++ b/examples/grpo/lora/wan21/i2v.yaml
@@ -28,7 +28,7 @@ model:
target_components: 'transformer' # Options: transformer
target_modules: "default" # Options: all, default, or list of module names like ["to_k", "to_q", "to_v", "to_out.0"]
model_name_or_path: "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers" # Wan-AI/Wan2.1-I2V-14B-480P-Diffusers / Wan-AI/Wan2.1-I2V-14B-720P-Diffusers
- model_type: "wan2_i2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_i2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/grpo/lora/wan21/t2v.yaml b/examples/grpo/lora/wan21/t2v.yaml
index df6063f0c..5eebfd8a4 100644
--- a/examples/grpo/lora/wan21/t2v.yaml
+++ b/examples/grpo/lora/wan21/t2v.yaml
@@ -28,7 +28,7 @@ model:
target_components: 'transformer' # Options: transformer (Wan2.1 & Wan2.2), transformer_2 (Wan2.2)
target_modules: "default" # Options: all, default, or list of module names like ["to_k", "to_q", "to_v", "to_out.0"]
model_name_or_path: "Wan-AI/Wan2.1-T2V-14B-Diffusers" # Wan-AI/Wan2.1-T2V-14B-Diffusers / Wan-AI/Wan2.2-T2V-A14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/grpo/lora/wan21/v2v.yaml b/examples/grpo/lora/wan21/v2v.yaml
deleted file mode 100644
index 09f16110a..000000000
--- a/examples/grpo/lora/wan21/v2v.yaml
+++ /dev/null
@@ -1,127 +0,0 @@
-# Environment Configuration
-launcher: "accelerate" # Options: accelerate
-config_file: config/deepspeed/deepspeed_zero2.yaml # Path to distributed config file (optional)
-num_processes: 8 # Number of processes to launch (overrides config file)
-main_process_port: 29500
-mixed_precision: "bf16" # Options: no, fp16, bf16
-
-# Data Configuration
-data:
- datasets:
- - name: default # Unique identifier (used in metrics, caching, reward routing)
- dataset_dir: "dataset/pickscore" # Folder with train.jsonl / test.jsonl
- train: # Training participation config
- weight: 1 # Mixing weight (integer); ratio with other sources determines batch allocation
- max_dataset_size: 1000 # Cap on number of training samples for this source
- eval: {} # Eval participation (inherits shared `eval:` section settings)
- preprocessing_batch_size: 8 # Batch size for preprocessing
- dataloader_num_workers: 16 # Number of workers for DataLoader
- force_reprocess: true # Force reprocessing of the dataset
- cache_dir: "~/.cache/flow_factory/datasets" # Cache directory for preprocessed datasets
- sampler_type: "auto" # Options: auto, distributed_k_repeat, group_contiguous
-
-# Model Configuration
-model:
- finetune_type: 'lora' # Options: full, lora
- lora_rank : 64
- lora_alpha : 128
- target_components: 'transformer' # Options: transformer
- target_modules: "default" # Options: all, default, or list of module names like ["to_k", "to_q", "to_v", "to_out.0"]
- model_name_or_path: "Wan-AI/Wan2.1-T2V-14B-Diffusers" # Wan-AI/Wan2.1-T2V-1.3B-Diffusers / Wan-AI/Wan2.1-T2V-14B-Diffusers
- model_type: "wan2_v2v"
- resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
- resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
-
-log:
- run_name: null # Run name (auto: {model_type}_{finetune_type}_{trainer_type}_{timestamp})
- project: "Flow-Factory-V2V" # Project name for logging
- logging_backend: "wandb" # Options: wandb, swanlab, tensorboard, none
- save_dir: "saves/" # Directory to save model checkpoints and logs
- save_freq: 20 # Save frequency in epochs (0 to disable)
- save_model_only: true # Save only the model weights (not optimizer, scheduler, etc.)
-
-# Training Configuration
-train:
- # Trainer settings
- trainer_type: 'grpo' # Options: 'grpo', 'nft', 'awm'
- advantage_aggregation: 'gdpo' # Options: 'sum', 'gdpo'
- # Clipping
- clip_range: 1.0e-4 # PPO/GRPO clipping range
- adv_clip_range: 5.0 # Advantage clipping range
- # KL div
- kl_type: 'v-based' # Options: 'x-based', 'v-based'
- kl_beta: 0 # KL divergence coefficient. Set ~1e-2 for 'x-based' and ~1e-3 for 'v-based'.
- ref_param_device: 'cuda' # Options: cpu, cuda
-
- # Sampling settings
- resolution: 256 # Can be int or [height, width]
- num_inference_steps: 10 # Number of timesteps
- guidance_scale: 5.0 # Guidance scale for sampling
- strength: 0.8 # Noise strength for noising the video input during training
-
- # Batch and sampling
- per_device_batch_size: 1 # Batch size per device
- group_size: 16 # Group size for GRPO sampling
- global_std: false # Use global std for advantage normalization
- unique_sample_num_per_epoch: 48 # Unique samples per group
- gradient_step_per_epoch: 2 # Gradient steps per epoch
- gradient_accumulation_steps: auto # Options: auto, or positive integer. When set, `gradient_step_per_epoch` is ignored.
-
- # Optimization
-
- # EMA
- ema_decay: 0.9 # EMA decay rate (0 to disable)
- ema_update_interval: 4 # EMA update interval (in epochs)
- ema_device: "cuda" # Device to store EMA model (options: cpu, cuda)
-
- # Gradient checkpointing
- enable_gradient_checkpointing: false # Enable gradient checkpointing to save memory with extra compute
- offload_samples_to_cpu: true # Required for video models: per-sample tensors are GB-scale; without offload, sample()/optimize() OOMs at num_batches_per_epoch > 1.
-
- # Seed
- seed: 42 # Random seed
-
-# Scheduler Configuration
-scheduler:
- dynamics_type: "Flow-SDE" # Options: Flow-SDE, Dance-SDE, CPS, ODE
- noise_level: 0.9 # Noise level for sampling
- num_sde_steps: 1 # Number of noise steps
- sde_steps: [3, 4, 5] # Custom noise window, noise steps are randomly selected from this list during training
- seed: 42 # Scheduler seed (for noise step selection)
- flow_shift: 3.0 # 5.0 for 720P, 3.0 for 480P (from WanVideoToVideoPipeline docstring)
-
-# Evaluation settings
-eval:
- resolution: 480 # Evaluation resolution
- per_device_batch_size: 1 # Eval batch size
- guidance_scale: 5.0 # Guidance scale for sampling
- num_inference_steps: 28 # Number of eval timesteps
- eval_freq: 20 # Eval frequency in epochs (0 to disable)
- seed: 42 # Eval seed (defaults to training seed)
-
-# Reward Model Configuration
-rewards:
- name: "pick_score"
- reward_model: "PickScore"
- batch_size: 16
- device: "cuda"
- dtype: bfloat16
-
-# Optional Evaluation Reward Models
-# eval_rewards:
-# - name: "text_alignment"
-# reward_model: "CLIP"
-# batch_size: 16
-# dtype: bfloat16
-# device: "cuda"
-
-
-# Optimizer Configuration
-# One entry per trainable variant. A single-policy run has exactly one.
-optimizers:
- - name: default
- learning_rate: 1.0e-4 # Initial learning rate
- weight_decay: 1.0e-4 # AdamW weight decay
- betas: [0.9, 0.999] # AdamW betas
- eps: 1.0e-8 # AdamW epsilon
- max_grad_norm: 1.0 # Max gradient norm for clipping
diff --git a/examples/grpo/lora/wan22/i2v.yaml b/examples/grpo/lora/wan22/i2v.yaml
index 90a9300c4..f70286a0b 100644
--- a/examples/grpo/lora/wan22/i2v.yaml
+++ b/examples/grpo/lora/wan22/i2v.yaml
@@ -39,7 +39,7 @@ model:
# But pay attention to the `boundary_ratio` that used in Wan2.2, before/after which different transformer is used and therefore needed for backward.
target_modules: "transformer.default"
model_name_or_path: "Wan-AI/Wan2.2-I2V-A14B-Diffusers" # Wan-AI/Wan2.2-TI2V-5B-Diffusers / Wan-AI/Wan2.2-I2V-A14B-Diffusers
- model_type: "wan2_i2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_i2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/grpo/lora/wan22/t2v.yaml b/examples/grpo/lora/wan22/t2v.yaml
index 88882d646..79f535747 100644
--- a/examples/grpo/lora/wan22/t2v.yaml
+++ b/examples/grpo/lora/wan22/t2v.yaml
@@ -39,7 +39,7 @@ model:
# But pay attention to the `boundary_ratio` that used in Wan2.2, before/after which different transformer is used and therefore needed for backward.
target_modules: "default"
model_name_or_path: "Wan-AI/Wan2.2-T2V-A14B-Diffusers" # Wan-AI/Wan2.1-T2V-14B-Diffusers / Wan-AI/Wan2.2-T2V-A14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/nft/full/wan22/t2v.yaml b/examples/nft/full/wan22/t2v.yaml
index c5460393a..35d96ec70 100644
--- a/examples/nft/full/wan22/t2v.yaml
+++ b/examples/nft/full/wan22/t2v.yaml
@@ -30,7 +30,7 @@ model:
target_components: 'transformer' # Options: transformer, transformer_2
target_modules: "default"
model_name_or_path: "Wan-AI/Wan2.2-T2V-A14B-Diffusers" # Wan-AI/Wan2.1-T2V-14B-Diffusers / Wan-AI/Wan2.2-T2V-A14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/examples/nft/lora/wan21/i2v.yaml b/examples/nft/lora/wan21/i2v.yaml
index 96ca85467..e7678528a 100644
--- a/examples/nft/lora/wan21/i2v.yaml
+++ b/examples/nft/lora/wan21/i2v.yaml
@@ -28,7 +28,7 @@ model:
target_components: 'transformer'
target_modules: "default"
model_name_or_path: "Wan-AI/Wan2.1-I2V-14B-720P-Diffusers" # Wan-AI/Wan2.1-I2V-14B-480P-Diffusers / Wan-AI/Wan2.1-I2V-14B-480P-Diffusers
- model_type: "wan2_i2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_i2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
# Optional acceleration plugins (off by default). Applied in list order.
diff --git a/examples/nft/lora/wan21/t2v.yaml b/examples/nft/lora/wan21/t2v.yaml
index e7e06cb3d..800ca1be3 100644
--- a/examples/nft/lora/wan21/t2v.yaml
+++ b/examples/nft/lora/wan21/t2v.yaml
@@ -28,7 +28,7 @@ model:
target_components: 'transformer'
target_modules: "default"
model_name_or_path: "Wan-AI/Wan2.1-T2V-1.3B-Diffusers" # Wan-AI/Wan2.1-T2V-1.3B-Diffusers / Wan-AI/Wan2.1-T2V-14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
# Optional acceleration plugins (off by default). Applied in list order.
diff --git a/examples/nft/lora/wan22/t2v.yaml b/examples/nft/lora/wan22/t2v.yaml
index e7c64b81a..327d175ef 100644
--- a/examples/nft/lora/wan22/t2v.yaml
+++ b/examples/nft/lora/wan22/t2v.yaml
@@ -28,7 +28,7 @@ model:
target_components: 'transformer' # Options: transformer, transformer_2, or ['transformer', 'transformer_2']
target_modules: "default"
model_name_or_path: "Wan-AI/Wan2.2-T2V-A14B-Diffusers" # Wan-AI/Wan2.2-TI2V-5B-Diffusers / Wan-AI/Wan2.2-T2V-A14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Local path or HF repo id (e.g. 'owner/repo[/subdir][@rev]') for previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
# Optional acceleration plugins (off by default). Applied in list order.
diff --git a/guidance/acceleration.md b/guidance/acceleration.md
index e6043b6e7..8aea06960 100644
--- a/guidance/acceleration.md
+++ b/guidance/acceleration.md
@@ -154,7 +154,7 @@ for unsupported adapters.
Cache-ready adapters are FLUX.2-Klein, Qwen-Image, Qwen-Image-Edit-Plus, Wan T2V/I2V, and
LTX2 T2AV/I2AV. Qwen merged CFG uses a shared `cond_uncond` context; no-CFG uses `cond`.
-FLUX.1/Kontext, FLUX.2, SD3.5, Z-Image, Wan V2V, and Bagel are not cache-ready. Validate
+FLUX.1/Kontext, FLUX.2, SD3.5, Z-Image, and Bagel are not cache-ready. Validate
the reward distribution before and after enabling caching on a supported model.
`torch_compile` is model-agnostic and applies to every adapter.
diff --git a/multinode_examples/train.yaml b/multinode_examples/train.yaml
index 7b5c65cb0..7ab5f959d 100644
--- a/multinode_examples/train.yaml
+++ b/multinode_examples/train.yaml
@@ -27,7 +27,7 @@ model:
lora_alpha : 128
target_modules: "default" # Options: all, default, or list of module names like ["to_k", "to_q", "to_v", "to_out.0"]
model_name_or_path: "Wan-AI/Wan2.1-T2V-1.3B-Diffusers" # Wan-AI/Wan2.1-T2V-14B-Diffusers / Wan-AI/Wan2.2-T2V-A14B-Diffusers
- model_type: "wan2_t2v" # wan2_t2v, wan2_i2v, wan2_v2v
+ model_type: "wan2_t2v" # wan2_t2v, wan2_i2v
resume_path: null # Path to load previous checkpoint/lora adapter
resume_type: null # Options: lora, full, state. Null to auto-detect based on `finetune_type`
diff --git a/src/flow_factory/hparams/model_args.py b/src/flow_factory/hparams/model_args.py
index 672405267..45e4e5da0 100644
--- a/src/flow_factory/hparams/model_args.py
+++ b/src/flow_factory/hparams/model_args.py
@@ -174,7 +174,6 @@ class ModelArguments(ArgABC):
"z-image",
"wan2_t2v",
"wan2_i2v",
- "wan2_v2v",
"bagel",
"sensenova",
"ltx2_t2av",
diff --git a/src/flow_factory/models/latent_geometry.py b/src/flow_factory/models/latent_geometry.py
index c6ebb1e83..501e0d4ff 100644
--- a/src/flow_factory/models/latent_geometry.py
+++ b/src/flow_factory/models/latent_geometry.py
@@ -21,7 +21,7 @@
- ``PACKED`` ``(B, Seq, C)`` -- FLUX*, Qwen-Image*, LTX2*, Bagel
- ``CONV`` ``(B, C, H, W)`` -- SD3.5, Z-Image
-- ``VIDEO`` ``(B, C, T, H, W)`` -- Wan2 T2V/I2V/V2V
+- ``VIDEO`` ``(B, C, T, H, W)`` -- Wan2 T2V/I2V
The geometry records *which axis plays which role*, never concrete sizes, so it
stays valid as resolution, frame count, or reference-image count change at runtime.
diff --git a/src/flow_factory/models/registry.py b/src/flow_factory/models/registry.py
index 114ca7be7..8cd641be8 100644
--- a/src/flow_factory/models/registry.py
+++ b/src/flow_factory/models/registry.py
@@ -38,7 +38,6 @@
"z-image": "flow_factory.models.z_image.z_image.ZImageAdapter",
"wan2_i2v": "flow_factory.models.wan.wan2_i2v.Wan2_I2V_Adapter",
"wan2_t2v": "flow_factory.models.wan.wan2_t2v.Wan2_T2V_Adapter",
- "wan2_v2v": "flow_factory.models.wan.wan2_v2v.Wan2_V2V_Adapter",
"bagel": "flow_factory.models.bagel.bagel.BagelAdapter",
"sensenova": "flow_factory.models.sensenova.sensenova.SenseNovaAdapter",
"ltx2_t2av": "flow_factory.models.ltx2.ltx2_t2av.LTX2_T2AV_Adapter",
diff --git a/src/flow_factory/models/wan/wan2_v2v.py b/src/flow_factory/models/wan/wan2_v2v.py
deleted file mode 100644
index 583508954..000000000
--- a/src/flow_factory/models/wan/wan2_v2v.py
+++ /dev/null
@@ -1,609 +0,0 @@
-# Copyright 2026 Jayce-Ping
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# src/flow_factory/models/wan/wan2_v2v.py
-from __future__ import annotations
-
-import logging
-import os
-from collections import defaultdict
-from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
-
-import numpy as np
-import torch
-from accelerate import Accelerator
-from diffusers.pipelines.wan.pipeline_wan_video2video import (
- WanVideoToVideoPipeline,
- prompt_clean,
- retrieve_timesteps,
-)
-from peft import PeftModel
-from PIL import Image
-
-from ...hparams import *
-from ...samples import V2VSample
-from ...scheduler import UniPCMultistepSDEScheduler, UniPCMultistepSDESchedulerOutput
-from ...utils.base import filter_kwargs
-from ...utils.logger_utils import setup_logger
-from ...utils.trajectory_collector import (
- CallbackCollector,
- TrajectoryCollector,
- TrajectoryIndicesType,
- create_callback_collector,
- create_trajectory_collector,
-)
-from ...utils.video import (
- MultiVideoBatch,
- VideoBatch,
- VideoSingle,
- is_multi_video_batch,
- is_video,
- is_video_batch,
- is_video_frame_list,
- standardize_video_batch,
-)
-from ..abc import BaseAdapter
-
-logger = setup_logger(__name__)
-
-
-WanPipelineVideoInput = Union[
- List[Image.Image], # One video as list of PIL images
- torch.Tensor, # One video as tensor (T, C, H, W) or a batch of videos (B, T, C, H, W)
- np.ndarray, # One video as numpy array (T, H, W, C) or a batch of videos (B, T, H, W, C)
- List[Union[torch.Tensor, np.ndarray, List[Image.Image]]], # A list of videos with various sizes
-]
-
-
-@dataclass
-class WanV2VSample(V2VSample):
- """Sample dataclass for Wan V2V outputs."""
-
- pass
-
-
-class Wan2_V2V_Adapter(BaseAdapter):
- # Wan2.2 trains both transformer and transformer_2 but uses only one per
- # timestep (boundary_ratio), so under DDP the other's trainable params get no
- # gradient in a given step. Ignored under DeepSpeed/FSDP.
- ddp_find_unused_parameters = True
- component_load_dtype_defaults = {
- "transformers": torch.bfloat16,
- "text_encoders": torch.bfloat16,
- "vae": torch.float32,
- }
-
- def __init__(self, config: Arguments, accelerator: Accelerator):
- super().__init__(config, accelerator)
- self._has_warned_multi_video_input = False
- self.pipeline: WanVideoToVideoPipeline
- self.scheduler: UniPCMultistepSDEScheduler
-
- def load_pipeline(self) -> WanVideoToVideoPipeline:
- return self._load_diffusers_pipeline(
- WanVideoToVideoPipeline,
- self.model_args.model_name_or_path,
- )
-
- def apply_lora(
- self,
- target_modules: Union[str, List[str]],
- components: Union[str, List[str]] = ["transformer", "transformer_2"],
- **kwargs,
- ) -> Union[PeftModel, Dict[str, PeftModel]]:
- return super().apply_lora(target_modules=target_modules, components=components, **kwargs)
-
- # ============================ Module Management ============================
- @property
- def default_target_modules(self) -> List[str]:
- """Default LoRA target modules for Wan transformer."""
- return [
- # --- Self Attention ---
- "attn1.to_q",
- "attn1.to_k",
- "attn1.to_v",
- "attn1.to_out.0",
- # --- Cross Attention ---
- "attn2.to_q",
- "attn2.to_k",
- "attn2.to_v",
- "attn2.to_out.0",
- # --- Feed Forward Network ---
- "ffn.net.0.proj",
- "ffn.net.2",
- ]
-
- @property
- def inference_modules(self) -> List[str]:
- """Modules that are required for inference and forward"""
- if self.pipeline.config.boundary_ratio is None or self.pipeline.config.boundary_ratio <= 0:
- return ["transformer", "vae"]
-
- if self.pipeline.config.boundary_ratio >= 1:
- return ["transformer_2", "vae"]
-
- return ["transformer", "transformer_2", "vae"]
-
- # ======================== Component Getters & Setters ========================
- @property
- def transformer_2(self) -> torch.nn.Module:
- return self.get_component("transformer_2")
-
- @transformer_2.setter
- def transformer_2(self, module: torch.nn.Module):
- self.set_component("transformer_2", module)
-
- # ============================ Encoding & Decoding ============================
- # --------------------------- Prompt Encoding --------------------------
- def _get_t5_prompt_embeds(
- self,
- prompt: Union[str, List[str]],
- max_sequence_length: int = 226,
- device: Optional[torch.device] = None,
- dtype: Optional[torch.dtype] = None,
- ):
- device = device or self.pipeline.text_encoder.device
- dtype = dtype or self.pipeline.text_encoder.dtype
-
- prompt = [prompt] if isinstance(prompt, str) else prompt
- prompt = [prompt_clean(u) for u in prompt]
- batch_size = len(prompt)
-
- text_inputs = self.tokenizer(
- prompt,
- padding="max_length",
- max_length=max_sequence_length,
- truncation=True,
- add_special_tokens=True,
- return_attention_mask=True,
- return_tensors="pt",
- )
- text_input_ids, mask = text_inputs.input_ids, text_inputs.attention_mask
- seq_lens = mask.gt(0).sum(dim=1).long()
-
- prompt_embeds = self.pipeline.text_encoder(
- text_input_ids.to(device), mask.to(device)
- ).last_hidden_state
- prompt_embeds = prompt_embeds.to(dtype=dtype, device=device)
- prompt_embeds = [u[:v] for u, v in zip(prompt_embeds, seq_lens)]
- prompt_embeds = torch.stack(
- [
- torch.cat([u, u.new_zeros(max_sequence_length - u.size(0), u.size(1))])
- for u in prompt_embeds
- ],
- dim=0,
- )
-
- return text_input_ids, prompt_embeds
-
- def encode_prompt(
- self,
- prompt: Union[str, List[str]],
- negative_prompt: Optional[Union[str, List[str]]] = None,
- guidance_scale: float = 5.0,
- max_sequence_length: int = 512,
- device: Optional[torch.device] = None,
- dtype: Optional[torch.dtype] = None,
- ):
- r"""
- Encodes the prompt into text encoder hidden states.
-
- Args:
- prompt (`str` or `List[str]`, *optional*):
- prompt to be encoded
- negative_prompt (`str` or `List[str]`, *optional*):
- The prompt or prompts not to guide the image generation. If not defined, one has to pass
- `negative_prompt_embeds` instead. Ignored when not using guidance (i.e., ignored if `guidance_scale` is
- less than `1`).
- guidance_scale (`float`, *optional*, defaults to `5.0`):
- Guidance scale for classifier-free guidance. CFG is enabled when `guidance_scale > 1.0`.
- device: (`torch.device`, *optional*):
- torch device
- dtype: (`torch.dtype`, *optional*):
- torch dtype
- """
- device = device or self.pipeline.text_encoder.device
- dtype = dtype or self.pipeline.text_encoder.dtype
- do_classifier_free_guidance = guidance_scale > 1.0
-
- prompt = [prompt] if isinstance(prompt, str) else prompt
- batch_size = len(prompt)
-
- prompt_ids, prompt_embeds = self._get_t5_prompt_embeds(
- prompt=prompt,
- max_sequence_length=max_sequence_length,
- device=device,
- dtype=dtype,
- )
-
- results = {
- "prompt_ids": prompt_ids,
- "prompt_embeds": prompt_embeds,
- }
-
- if do_classifier_free_guidance:
- negative_prompt = negative_prompt or ""
- negative_prompt = (
- [negative_prompt] if isinstance(negative_prompt, str) else negative_prompt
- )
- negative_prompt = negative_prompt * (
- len(prompt) // len(negative_prompt)
- ) # Expand to match batch size
- assert len(negative_prompt) == len(
- prompt
- ), "The number of negative prompts must match the number of prompts."
-
- negative_prompt_ids, negative_prompt_embeds = self._get_t5_prompt_embeds(
- prompt=negative_prompt,
- max_sequence_length=max_sequence_length,
- device=device,
- dtype=dtype,
- )
- results.update(
- {
- "negative_prompt_ids": negative_prompt_ids,
- "negative_prompt_embeds": negative_prompt_embeds,
- }
- )
-
- return results
-
- # --------------------------- Image Encoding --------------------------
- def encode_image(self, images: Union[Image.Image, torch.Tensor, List[torch.Tensor]]) -> None:
- """Skip this for Wan V2V as the pipeline handles encoding internally."""
- pass
-
- # --------------------------- Video Encoding --------------------------
- def encode_video(self, videos: Union[torch.Tensor, List[torch.Tensor]]) -> None:
- """Skip this for Wan V2V as the pipeline handles encoding internally."""
- pass
-
- def _standardize_video_input(
- self,
- videos: Union[VideoSingle, VideoBatch, MultiVideoBatch],
- output_type: Literal["np", "pt", "pil"] = "pt",
- ) -> VideoBatch:
- """Convert a batch/list of videos into the target format."""
- if is_video_frame_list(videos):
- # One video as list of PIL images
- videos = [videos]
- if is_multi_video_batch(videos):
- # A list of video batches
- if any(len(batch) > 1 for batch in videos) and not self._has_warned_multi_video_input:
- self._has_warned_multi_video_input = True
- logger.warning(
- "Multiple condition videos are not supported for Wan2 V2V. Only the first video of each batch will be used."
- )
- videos = [batch[0] for batch in videos]
- # To a batch of videos
- standardized_videos = standardize_video_batch(
- videos,
- output_type=output_type,
- )
- return standardized_videos
-
- # --------------------------- Video Decoding --------------------------
- def decode_latents(
- self, latents: torch.Tensor, output_type: Literal["pt", "pil", "np"] = "pil"
- ) -> torch.Tensor:
- """Decode the latents using the VAE decoder."""
- latents = latents.float()
- latents_mean = (
- torch.tensor(self.pipeline.vae.config.latents_mean)
- .view(1, self.pipeline.vae.config.z_dim, 1, 1, 1)
- .to(latents.device, latents.dtype)
- )
- latents_std = 1.0 / torch.tensor(self.pipeline.vae.config.latents_std).view(
- 1, self.pipeline.vae.config.z_dim, 1, 1, 1
- ).to(latents.device, latents.dtype)
- latents = latents / latents_std + latents_mean
- video = self.pipeline.vae.decode(latents, return_dict=False)[0]
-
- video = self.pipeline.video_processor.postprocess_video(video, output_type=output_type)
- return video
-
- # ============================ Inference ============================
- @torch.no_grad()
- def inference(
- self,
- # Ordinary inputs
- videos: Union[VideoSingle, VideoBatch, MultiVideoBatch],
- prompt: Union[str, List[str]] = None,
- negative_prompt: Union[str, List[str]] = None,
- height: int = 480,
- width: int = 832,
- num_inference_steps: int = 50,
- guidance_scale: float = 5.0,
- strength: float = 0.8,
- generator: Optional[Union[torch.Generator, List[torch.Generator]]] = None,
- # Encoded Prompt
- prompt_ids: Optional[torch.Tensor] = None,
- prompt_embeds: Optional[torch.Tensor] = None,
- negative_prompt_ids: Optional[torch.Tensor] = None,
- negative_prompt_embeds: Optional[torch.Tensor] = None,
- # Other kwargs
- compute_log_prob: bool = False,
- attention_kwargs: Optional[Dict[str, Any]] = None,
- max_sequence_length: int = 512,
- extra_call_back_kwargs: List[str] = [],
- trajectory_indices: TrajectoryIndicesType = "all",
- ) -> List[WanV2VSample]:
- # 1. Setup args
- device = self.device
- dtype = self.pipeline.transformer.dtype
- do_classifier_free_guidance = guidance_scale > 1.0
- height = (
- height
- or self.pipeline.transformer.config.sample_height
- * self.pipeline.vae_scale_factor_spatial
- )
- width = (
- width
- or self.pipeline.transformer.config.sample_width
- * self.pipeline.vae_scale_factor_spatial
- )
-
- # 2. Encode prompt
- if prompt_embeds is None or negative_prompt_embeds is None:
- encoded = self.encode_prompt(
- prompt=prompt,
- negative_prompt=negative_prompt,
- guidance_scale=guidance_scale,
- max_sequence_length=max_sequence_length,
- device=device,
- )
- prompt_ids = encoded["prompt_ids"]
- prompt_embeds = encoded["prompt_embeds"]
- negative_prompt_ids = encoded.get("negative_prompt_ids", None)
- negative_prompt_embeds = encoded.get("negative_prompt_embeds", None)
- else:
- prompt_embeds = prompt_embeds.to(device)
- if negative_prompt_embeds is not None:
- negative_prompt_embeds = negative_prompt_embeds.to(device)
-
- batch_size = prompt_embeds.shape[0]
-
- # 3. Set timesteps
- input_inference_steps = num_inference_steps # 50
- timesteps, num_inference_steps = retrieve_timesteps(
- self.scheduler, num_inference_steps, device
- ) # [1000, ..., 0], 50
- timesteps, num_inference_steps = self.pipeline.get_timesteps(
- num_inference_steps, timesteps, strength, device
- ) # strength=0.8, [800, ..., 0], 40
- latent_timestep = timesteps[:1].repeat(batch_size)
- self.pipeline._num_timesteps = len(timesteps)
-
- # 4. Prepare latents
- videos = self._standardize_video_input(videos, output_type="pt")
- videos = self.pipeline.video_processor.preprocess_video(
- videos, height=height, width=width
- ).to(device, dtype=dtype)
-
- num_channels_latents = self.pipeline.transformer.config.in_channels
- latents = self.pipeline.prepare_latents(
- video=videos,
- batch_size=batch_size,
- num_channels_latents=num_channels_latents,
- height=height,
- width=width,
- dtype=torch.float32,
- device=device,
- generator=generator,
- latents=None,
- timestep=latent_timestep,
- )
-
- # 5. Denoising loop
- num_warmup_steps = len(timesteps) - num_inference_steps * self.scheduler.order
- self.pipeline._num_timesteps = len(timesteps)
-
- latent_collector = create_trajectory_collector(trajectory_indices, num_inference_steps)
- latents = self.cast_latents(latents)
- latent_collector.collect(latents, step_idx=0)
- if compute_log_prob:
- log_prob_collector = create_trajectory_collector(
- trajectory_indices, num_inference_steps
- )
- callback_collector = create_callback_collector(trajectory_indices, num_inference_steps)
-
- for i, t in enumerate(timesteps):
- current_noise_level = self.scheduler.get_noise_level_for_timestep(t)
- t_next = timesteps[i + 1] if i + 1 < len(timesteps) else torch.tensor(0, device=device)
- return_kwargs = list(
- set(["next_latents", "log_prob", "velocity"] + extra_call_back_kwargs)
- )
- current_compute_log_prob = compute_log_prob and current_noise_level > 0
-
- output = self.forward(
- t=t,
- t_next=t_next,
- latents=latents,
- prompt_embeds=prompt_embeds,
- negative_prompt_embeds=negative_prompt_embeds,
- guidance_scale=guidance_scale,
- attention_kwargs=attention_kwargs,
- compute_log_prob=current_compute_log_prob,
- return_kwargs=return_kwargs,
- noise_level=current_noise_level,
- )
-
- latents = self.cast_latents(output.next_latents)
- latent_collector.collect(latents, i + 1)
- if current_compute_log_prob:
- log_prob_collector.collect(output.log_prob, i)
-
- callback_collector.collect_step(
- step_idx=i,
- output=output,
- keys=extra_call_back_kwargs,
- capturable={"noise_level": current_noise_level},
- )
-
- self._current_timestep = None
-
- # 7. Decode latents to videos (list of pil images)
- decoded_videos = self.decode_latents(latents, output_type="pt")
-
- # 8. Prepare output samples
- extra_call_back_res = callback_collector.get_result() # (B, len(trajectory_indices), ...)
- callback_index_map = callback_collector.get_index_map() # (T,) LongTensor
- all_latents = latent_collector.get_result() # List[torch.Tensor(B, ...)]
- latent_index_map = latent_collector.get_index_map() # (T+1,) LongTensor
- all_log_probs = log_prob_collector.get_result() if compute_log_prob else None
- log_prob_index_map = log_prob_collector.get_index_map() if compute_log_prob else None
- samples = [
- WanV2VSample(
- # Denoising trajectory
- timesteps=timesteps,
- all_latents=(
- torch.stack([lat[b] for lat in all_latents], dim=0)
- if all_latents is not None
- else None
- ),
- log_probs=(
- torch.stack([lp[b] for lp in all_log_probs], dim=0)
- if all_log_probs is not None
- else None
- ),
- latent_index_map=latent_index_map,
- log_prob_index_map=log_prob_index_map,
- # Generated video & metadata
- video=decoded_videos[b],
- height=height,
- width=width,
- # Prompt info
- prompt=prompt[b] if isinstance(prompt, list) else prompt,
- prompt_ids=prompt_ids[b],
- prompt_embeds=prompt_embeds[b],
- # Negative prompt info
- negative_prompt=(
- negative_prompt[b] if isinstance(negative_prompt, list) else negative_prompt
- ),
- negative_prompt_ids=(
- negative_prompt_ids[b] if negative_prompt_ids is not None else None
- ),
- negative_prompt_embeds=(
- negative_prompt_embeds[b] if negative_prompt_embeds is not None else None
- ),
- # Condition Video
- condition_videos=videos[b],
- # Extra kwargs
- extra_kwargs={
- **{k: v[b] for k, v in extra_call_back_res.items()},
- "callback_index_map": callback_index_map,
- },
- )
- for b in range(batch_size)
- ]
-
- self.pipeline.maybe_free_model_hooks()
-
- return samples
-
- # =========================== Forward ===========================
- def forward(
- self,
- t: torch.Tensor,
- latents: torch.Tensor,
- prompt_embeds: torch.Tensor,
- # Optional for CFG
- negative_prompt_embeds: Optional[torch.Tensor] = None,
- guidance_scale: float = 5.0,
- # Next timestep info
- t_next: Optional[torch.Tensor] = None,
- next_latents: Optional[torch.Tensor] = None,
- # Other
- noise_level: Optional[float] = None,
- attention_kwargs: Optional[Dict[str, Any]] = None,
- compute_log_prob: bool = True,
- return_kwargs: List[str] = [
- "velocity",
- "next_latents",
- "next_latents_mean",
- "std_dev_t",
- "dt",
- "log_prob",
- ],
- ) -> UniPCMultistepSDESchedulerOutput:
- """
- Core forward pass for V2V generation.
-
- Args:
- t: Current timestep tensor.
- t_next: Next timestep tensor.
- latents: Current latent representations (B, C, T, H, W).
- prompt_embeds: Text prompt embeddings.
- negative_prompt_embeds: Optional negative prompt embeddings (for CFG).
- guidance_scale: CFG scale factor.
- next_latents: Optional target latents for log-prob computation.
- noise_level: Current noise level for SDE sampling.
- attention_kwargs: Optional kwargs for attention layers.
- compute_log_prob: Whether to compute log probabilities.
- return_kwargs: List of outputs to return.
-
- Returns:
- UniPCMultistepSDESchedulerOutput containing requested outputs.
- """
- # 1. Prepare variables
- batch_size = latents.shape[0]
- device = latents.device
- dtype = self.pipeline.transformer.dtype
-
- if guidance_scale > 1.0 and negative_prompt_embeds is None:
- logger.warning(
- "Passed `guidance_scale` > 1.0, but no `negative_prompt_embeds` provided. "
- "Classifier-free guidance will be disabled."
- )
- do_classifier_free_guidance = negative_prompt_embeds is not None and guidance_scale > 1.0
-
- # 2. Prepare timestep
- timestep = t.expand(batch_size)
- latent_model_input = latents.to(dtype)
-
- # 3. Transformer forward pass
- velocity = self.transformer(
- hidden_states=latent_model_input,
- timestep=timestep,
- encoder_hidden_states=prompt_embeds,
- attention_kwargs=attention_kwargs,
- return_dict=False,
- )[0]
-
- # 4. Apply CFG
- if do_classifier_free_guidance:
- velocity_uncond = self.transformer(
- hidden_states=latent_model_input,
- timestep=timestep,
- encoder_hidden_states=negative_prompt_embeds,
- attention_kwargs=attention_kwargs,
- return_dict=False,
- )[0]
- velocity = velocity_uncond + guidance_scale * (velocity - velocity_uncond)
-
- # 5. Scheduler step
- output = self.scheduler.step(
- velocity=velocity,
- timestep=t,
- latents=latents,
- timestep_next=t_next,
- next_latents=next_latents,
- compute_log_prob=compute_log_prob,
- return_dict=True,
- return_kwargs=return_kwargs,
- noise_level=noise_level,
- )
-
- return output
diff --git a/tests/docs/test_sensenova_docs.py b/tests/docs/test_sensenova_docs.py
index 9221fa17b..60d6310b4 100644
--- a/tests/docs/test_sensenova_docs.py
+++ b/tests/docs/test_sensenova_docs.py
@@ -74,7 +74,7 @@ def test_internal_docs_distinguish_sensenova_from_bagel_packing() -> None:
assert "rather than Bagel-style NaViT packing" in architecture
assert "SenseNova ragged I2I is per-sample, not NaViT-packed" in conventions
assert "SenseNovaI2ISample" in conventions
- assert "all 15 adapters" in conventions
+ assert "all 14 adapters" in conventions
assert "SD3.5, Z-Image, SenseNova" in conventions
diff --git a/tests/models/test_all_model_loaders.py b/tests/models/test_all_model_loaders.py
index e08549add..5154ec67b 100644
--- a/tests/models/test_all_model_loaders.py
+++ b/tests/models/test_all_model_loaders.py
@@ -36,7 +36,6 @@
("flow_factory.models.z_image.z_image", "ZImageAdapter"),
("flow_factory.models.wan.wan2_t2v", "Wan2_T2V_Adapter"),
("flow_factory.models.wan.wan2_i2v", "Wan2_I2V_Adapter"),
- ("flow_factory.models.wan.wan2_v2v", "Wan2_V2V_Adapter"),
("flow_factory.models.ltx2.ltx2_t2av", "LTX2_T2AV_Adapter"),
("flow_factory.models.ltx2.ltx2_i2av", "LTX2_I2AV_Adapter"),
)
@@ -223,7 +222,6 @@ def test_model_specific_load_dtype_defaults_are_explicit_and_narrow() -> None:
)
from flow_factory.models.wan.wan2_i2v import Wan2_I2V_Adapter
from flow_factory.models.wan.wan2_t2v import Wan2_T2V_Adapter
- from flow_factory.models.wan.wan2_v2v import Wan2_V2V_Adapter
from flow_factory.models.z_image.z_image import ZImageAdapter
assert {
@@ -248,7 +246,6 @@ def test_model_specific_load_dtype_defaults_are_explicit_and_narrow() -> None:
"vae": torch.float32,
}
assert Wan2_T2V_Adapter.component_load_dtype_defaults == expected_wan_defaults
- assert Wan2_V2V_Adapter.component_load_dtype_defaults == expected_wan_defaults
assert Wan2_I2V_Adapter.component_load_dtype_defaults == {
**expected_wan_defaults,
"image_encoder": torch.float32,
diff --git a/tests/models/test_wan_registry.py b/tests/models/test_wan_registry.py
new file mode 100644
index 000000000..a43f1bcd5
--- /dev/null
+++ b/tests/models/test_wan_registry.py
@@ -0,0 +1,31 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import get_args, get_type_hints
+
+import pytest
+
+from flow_factory.hparams import ModelArguments
+from flow_factory.models.registry import get_model_adapter_class, list_registered_models
+
+
+def test_removed_wan_v2v_adapter_is_not_publicly_available() -> None:
+ registered_models = list_registered_models()
+ model_type_values = set(get_args(get_type_hints(ModelArguments)["model_type"]))
+
+ assert "wan2_v2v" not in registered_models
+ assert "wan2_v2v" not in model_type_values
+
+ with pytest.raises(ImportError, match="wan2_v2v"):
+ get_model_adapter_class("wan2_v2v")
From 74c76538c4c21be27824cc8a6fbc2d6d07b72d73 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:01:40 +0800
Subject: [PATCH 02/76] refactor(dpo): share pairwise objective
---
src/flow_factory/trainers/common/__init__.py | 2 +
.../trainers/common/dpo_objective.py | 126 ++++++++++++++
src/flow_factory/trainers/rl/dpo.py | 26 +--
tests/trainers/test_dpo_objective.py | 162 ++++++++++++++++++
4 files changed, 298 insertions(+), 18 deletions(-)
create mode 100644 src/flow_factory/trainers/common/dpo_objective.py
create mode 100644 tests/trainers/test_dpo_objective.py
diff --git a/src/flow_factory/trainers/common/__init__.py b/src/flow_factory/trainers/common/__init__.py
index f9f39bc46..828ac95a1 100644
--- a/src/flow_factory/trainers/common/__init__.py
+++ b/src/flow_factory/trainers/common/__init__.py
@@ -1,5 +1,6 @@
"""Shared, algorithm-independent trainer primitives."""
+from .dpo_objective import dpo_objective
from .forward_kwargs import (
reference_forward_kwargs,
replay_forward_kwargs,
@@ -15,6 +16,7 @@
)
__all__ = [
+ "dpo_objective",
"iter_prefetched_batches",
"move_and_stack_samples",
"reference_forward_kwargs",
diff --git a/src/flow_factory/trainers/common/dpo_objective.py b/src/flow_factory/trainers/common/dpo_objective.py
new file mode 100644
index 000000000..618b803c7
--- /dev/null
+++ b/src/flow_factory/trainers/common/dpo_objective.py
@@ -0,0 +1,126 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Pure pairwise DPO objective shared by online and offline trainers."""
+
+from __future__ import annotations
+
+import math
+from numbers import Real
+from typing import Dict, Tuple
+
+import torch
+import torch.nn.functional as F
+
+
+def dpo_objective(
+ policy_chosen_loss: torch.Tensor,
+ policy_rejected_loss: torch.Tensor,
+ reference_chosen_loss: torch.Tensor,
+ reference_rejected_loss: torch.Tensor,
+ beta: float,
+) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]:
+ """Compute pairwise diffusion-DPO from aligned per-sample losses.
+
+ Lower per-sample losses correspond to higher implicit rewards. The scalar
+ objective and metric definitions intentionally preserve the online DPO
+ trainer's historical implementation exactly.
+ """
+ _validate_dpo_inputs(
+ policy_chosen_loss=policy_chosen_loss,
+ policy_rejected_loss=policy_rejected_loss,
+ reference_chosen_loss=reference_chosen_loss,
+ reference_rejected_loss=reference_rejected_loss,
+ beta=beta,
+ )
+
+ chosen_delta = policy_chosen_loss - reference_chosen_loss
+ rejected_delta = policy_rejected_loss - reference_rejected_loss
+ preference_delta = chosen_delta - rejected_delta
+ loss = -F.logsigmoid(-0.5 * beta * preference_delta).mean()
+ with torch.no_grad():
+ implicit_reward_chosen = -0.5 * beta * chosen_delta
+ implicit_reward_rejected = -0.5 * beta * rejected_delta
+ metrics = {
+ "implicit_reward_chosen": implicit_reward_chosen,
+ "implicit_reward_rejected": implicit_reward_rejected,
+ "implicit_accuracy": (implicit_reward_chosen > implicit_reward_rejected).float().mean(),
+ }
+ return loss, metrics
+
+
+def _validate_dpo_inputs(
+ *,
+ policy_chosen_loss: torch.Tensor,
+ policy_rejected_loss: torch.Tensor,
+ reference_chosen_loss: torch.Tensor,
+ reference_rejected_loss: torch.Tensor,
+ beta: float,
+) -> None:
+ named_losses = {
+ "policy_chosen_loss": policy_chosen_loss,
+ "policy_rejected_loss": policy_rejected_loss,
+ "reference_chosen_loss": reference_chosen_loss,
+ "reference_rejected_loss": reference_rejected_loss,
+ }
+ for name, values in named_losses.items():
+ if not isinstance(values, torch.Tensor):
+ raise TypeError(
+ f"expected {name} for DPO objective to be a torch.Tensor, "
+ f"received {type(values).__name__}: {values!r}"
+ )
+ if values.ndim != 1 or values.shape[0] == 0:
+ raise ValueError(
+ f"expected {name} for DPO objective to have non-empty shape (B,), "
+ f"received shape {tuple(values.shape)}"
+ )
+ if not values.is_floating_point():
+ raise TypeError(
+ f"expected {name} for DPO objective to use a floating dtype, "
+ f"received dtype {values.dtype}"
+ )
+
+ expected_shape = policy_chosen_loss.shape
+ expected_dtype = policy_chosen_loss.dtype
+ expected_device = policy_chosen_loss.device
+ for name, values in tuple(named_losses.items())[1:]:
+ if values.shape != expected_shape:
+ raise ValueError(
+ "expected all DPO per-sample losses to have the same shape, "
+ f"but policy_chosen_loss has {tuple(expected_shape)} and {name} has "
+ f"{tuple(values.shape)}"
+ )
+ if values.dtype != expected_dtype:
+ raise TypeError(
+ "expected all DPO per-sample losses to have the same dtype, "
+ f"but policy_chosen_loss uses {expected_dtype} and {name} uses "
+ f"{values.dtype}"
+ )
+ if values.device != expected_device:
+ raise ValueError(
+ "expected all DPO per-sample losses on the same device, "
+ f"but policy_chosen_loss is on {expected_device} and {name} is on "
+ f"{values.device}"
+ )
+
+ if isinstance(beta, bool) or not isinstance(beta, Real):
+ raise TypeError(
+ "expected beta for DPO objective to be a finite real number, "
+ f"received {type(beta).__name__}: {beta!r}"
+ )
+ if not math.isfinite(float(beta)):
+ raise ValueError(f"expected finite beta for DPO objective, received beta={beta!r}")
+
+
+__all__ = ["dpo_objective"]
diff --git a/src/flow_factory/trainers/rl/dpo.py b/src/flow_factory/trainers/rl/dpo.py
index a1fd53086..5b2e657b2 100644
--- a/src/flow_factory/trainers/rl/dpo.py
+++ b/src/flow_factory/trainers/rl/dpo.py
@@ -33,7 +33,6 @@
import numpy as np
import torch
import torch.distributed as dist
-import torch.nn.functional as F
import tqdm as tqdm_
from accelerate.utils import broadcast_object_list
@@ -46,6 +45,7 @@
from ...utils.logger_utils import setup_logger
from ...utils.noise_schedule import TimeSampler
from ..abc import BaseTrainer
+from ..common import dpo_objective
from ..common.state_validation import require_latent_state, state_batch_size
from ..forward_process import forward_velocity_state
@@ -448,23 +448,13 @@ def _preference_loss(
Returns:
Scalar loss and the per-sample implicit reward / accuracy metrics.
"""
- beta = self.training_args.beta
- w_diff = theta_w_err - ref_w_err
- l_diff = theta_l_err - ref_l_err
- w_l_diff = w_diff - l_diff
- inside_term = -0.5 * beta * w_l_diff
- loss = -F.logsigmoid(inside_term).mean()
- with torch.no_grad():
- implicit_reward_chosen = -0.5 * beta * w_diff
- implicit_reward_rejected = -0.5 * beta * l_diff
- metrics = {
- "implicit_reward_chosen": implicit_reward_chosen,
- "implicit_reward_rejected": implicit_reward_rejected,
- "implicit_accuracy": (implicit_reward_chosen > implicit_reward_rejected)
- .float()
- .mean(),
- }
- return loss, metrics
+ return dpo_objective(
+ policy_chosen_loss=theta_w_err,
+ policy_rejected_loss=theta_l_err,
+ reference_chosen_loss=ref_w_err,
+ reference_rejected_loss=ref_l_err,
+ beta=self.training_args.beta,
+ )
# ====================== Reward / advantage (Stages 4--5) ======================
def prepare_feedback(self, samples: List[BaseSample]) -> None:
diff --git a/tests/trainers/test_dpo_objective.py b/tests/trainers/test_dpo_objective.py
new file mode 100644
index 000000000..a566c10c7
--- /dev/null
+++ b/tests/trainers/test_dpo_objective.py
@@ -0,0 +1,162 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from types import SimpleNamespace
+from typing import Dict, Tuple
+
+import pytest
+import torch
+import torch.nn.functional as F
+
+from flow_factory.trainers.common.dpo_objective import dpo_objective
+from flow_factory.trainers.rl import dpo as dpo_module
+from flow_factory.trainers.rl.dpo import DPOTrainer
+
+
+def _legacy_online_objective(
+ policy_chosen_loss: torch.Tensor,
+ policy_rejected_loss: torch.Tensor,
+ reference_chosen_loss: torch.Tensor,
+ reference_rejected_loss: torch.Tensor,
+ beta: float,
+) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]:
+ chosen_delta = policy_chosen_loss - reference_chosen_loss
+ rejected_delta = policy_rejected_loss - reference_rejected_loss
+ preference_delta = chosen_delta - rejected_delta
+ loss = -F.logsigmoid(-0.5 * beta * preference_delta).mean()
+ with torch.no_grad():
+ implicit_reward_chosen = -0.5 * beta * chosen_delta
+ implicit_reward_rejected = -0.5 * beta * rejected_delta
+ metrics = {
+ "implicit_reward_chosen": implicit_reward_chosen,
+ "implicit_reward_rejected": implicit_reward_rejected,
+ "implicit_accuracy": (implicit_reward_chosen > implicit_reward_rejected).float().mean(),
+ }
+ return loss, metrics
+
+
+def test_objective_matches_online_values_and_logging_metrics_exactly() -> None:
+ inputs = (
+ torch.tensor([1.0, 4.0, 2.5], dtype=torch.float64),
+ torch.tensor([3.0, 2.0, 2.5], dtype=torch.float64),
+ torch.tensor([2.0, 2.0, 2.5], dtype=torch.float64),
+ torch.tensor([2.0, 2.0, 2.5], dtype=torch.float64),
+ )
+
+ actual_loss, actual_metrics = dpo_objective(*inputs, beta=2.0)
+ expected_loss, expected_metrics = _legacy_online_objective(*inputs, beta=2.0)
+
+ torch.testing.assert_close(actual_loss, expected_loss, rtol=0, atol=0)
+ assert tuple(actual_metrics) == (
+ "implicit_reward_chosen",
+ "implicit_reward_rejected",
+ "implicit_accuracy",
+ )
+ for name in actual_metrics:
+ torch.testing.assert_close(actual_metrics[name], expected_metrics[name], rtol=0, atol=0)
+ torch.testing.assert_close(actual_metrics["implicit_accuracy"], torch.tensor(1.0 / 3.0))
+ assert not any(metric.requires_grad for metric in actual_metrics.values())
+
+
+def test_objective_preserves_the_legacy_gradient_for_all_four_inputs() -> None:
+ values = (
+ torch.tensor([0.4, 1.2], dtype=torch.float64),
+ torch.tensor([1.7, 0.5], dtype=torch.float64),
+ torch.tensor([0.8, 0.9], dtype=torch.float64),
+ torch.tensor([1.1, 0.7], dtype=torch.float64),
+ )
+ actual_inputs = tuple(value.clone().requires_grad_() for value in values)
+ expected_inputs = tuple(value.clone().requires_grad_() for value in values)
+
+ actual_loss, _ = dpo_objective(*actual_inputs, beta=7.0)
+ expected_loss, _ = _legacy_online_objective(*expected_inputs, beta=7.0)
+ actual_loss.backward()
+ expected_loss.backward()
+
+ torch.testing.assert_close(actual_loss, expected_loss, rtol=0, atol=0)
+ for actual, expected in zip(actual_inputs, expected_inputs):
+ torch.testing.assert_close(actual.grad, expected.grad, rtol=0, atol=0)
+
+
+def test_extreme_preference_logits_remain_finite() -> None:
+ loss, _ = dpo_objective(
+ torch.tensor([1.0e6, -1.0e6]),
+ torch.tensor([-1.0e6, 1.0e6]),
+ torch.zeros(2),
+ torch.zeros(2),
+ beta=2000.0,
+ )
+ assert torch.isfinite(loss)
+
+
+def test_online_trainer_delegates_without_changing_metrics(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ trainer = object.__new__(DPOTrainer)
+ trainer.training_args = SimpleNamespace(beta=3.5)
+ values = tuple(torch.tensor([float(i), float(i + 1)]) for i in range(1, 8, 2))
+ sentinel_loss = torch.tensor(9.0)
+ sentinel_metrics = {
+ "implicit_reward_chosen": torch.tensor([10.0, 11.0]),
+ "implicit_reward_rejected": torch.tensor([12.0, 13.0]),
+ "implicit_accuracy": torch.tensor(0.5),
+ }
+ received: Dict[str, object] = {}
+
+ def fake_objective(**kwargs: object):
+ received.update(kwargs)
+ return sentinel_loss, sentinel_metrics
+
+ monkeypatch.setattr(dpo_module, "dpo_objective", fake_objective)
+ loss, metrics = trainer._preference_loss(*values)
+
+ assert received == {
+ "policy_chosen_loss": values[0],
+ "policy_rejected_loss": values[1],
+ "reference_chosen_loss": values[2],
+ "reference_rejected_loss": values[3],
+ "beta": 3.5,
+ }
+ assert loss is sentinel_loss
+ assert metrics is sentinel_metrics
+
+
+@pytest.mark.parametrize(
+ "invalid_loss",
+ [torch.tensor(1.0), torch.empty(0), torch.ones(2, 1)],
+)
+def test_objective_requires_non_empty_per_sample_vectors(invalid_loss: torch.Tensor) -> None:
+ with pytest.raises(ValueError, match=r"policy_chosen_loss.*shape \(B,\)"):
+ dpo_objective(invalid_loss, torch.ones(2), torch.ones(2), torch.ones(2), beta=1.0)
+
+
+def test_objective_rejects_misaligned_shapes_and_dtypes() -> None:
+ with pytest.raises(ValueError, match="same shape"):
+ dpo_objective(torch.ones(2), torch.ones(3), torch.ones(2), torch.ones(2), beta=1.0)
+ with pytest.raises(TypeError, match="same dtype"):
+ dpo_objective(
+ torch.ones(2, dtype=torch.float32),
+ torch.ones(2, dtype=torch.float64),
+ torch.ones(2, dtype=torch.float32),
+ torch.ones(2, dtype=torch.float32),
+ beta=1.0,
+ )
+
+
+@pytest.mark.parametrize("beta", [True, "1.0", float("inf"), float("nan")])
+def test_objective_requires_a_finite_real_beta(beta: object) -> None:
+ values = torch.ones(2)
+ error_type = TypeError if isinstance(beta, (bool, str)) else ValueError
+ with pytest.raises(error_type, match="beta"):
+ dpo_objective(values, values, values, values, beta=beta) # type: ignore[arg-type]
From f049dd760b28dbd0a0cbc63ff35fc5ed3d424336 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:06:32 +0800
Subject: [PATCH 03/76] feat(training): sample independent offline timesteps
---
src/flow_factory/utils/noise_schedule.py | 69 ++++++++++++++++++++++++
tests/test_noise_schedule.py | 65 ++++++++++++++++++++++
2 files changed, 134 insertions(+)
diff --git a/src/flow_factory/utils/noise_schedule.py b/src/flow_factory/utils/noise_schedule.py
index 4c4bc0bd8..601d71ad0 100644
--- a/src/flow_factory/utils/noise_schedule.py
+++ b/src/flow_factory/utils/noise_schedule.py
@@ -262,6 +262,40 @@ def logit_normal_shifted(
t = TIMESTEP_MAX * (1.0 - frac)
return t.unsqueeze(1).expand(num_timesteps, batch_size)
+ @staticmethod
+ def independent_logit_normal_shifted(
+ batch_size: int,
+ num_timesteps: int,
+ timestep_range: Union[float, Tuple[float, float]],
+ logit_mean: float = 0.0,
+ logit_std: float = 1.0,
+ time_shift: float = 1.0,
+ device: torch.device = torch.device("cpu"),
+ generator: Optional[torch.Generator] = None,
+ ) -> torch.Tensor:
+ """Draw an independent logit-normal coordinate per term and sample.
+
+ Unlike :meth:`logit_normal_shifted`, this offline-oriented method
+ materializes ``(num_timesteps, batch_size)`` rather than expanding one
+ coordinate across each batch row. The legacy online RNG path remains
+ unchanged.
+ """
+ _require_positive_int(batch_size, "batch_size")
+ _require_positive_int(num_timesteps, "num_timesteps")
+ output_device = torch.device(device)
+ rng_device = _rng_device(generator, output_device)
+ u_standard = torch.randn(
+ (num_timesteps, batch_size),
+ generator=generator,
+ device=rng_device,
+ )
+ raw = torch.sigmoid(u_standard * logit_std + logit_mean)
+ raw = time_shift * raw / (1 + (time_shift - 1) * raw)
+ raw = torch.clamp(raw, min=0.01, max=1.0 - 1e-6)
+ frac_lo, frac_hi = _normalize_timestep_range(timestep_range)
+ frac = frac_lo + raw * (frac_hi - frac_lo)
+ return (TIMESTEP_MAX * (1.0 - frac)).to(output_device)
+
@staticmethod
def uniform(
batch_size: int,
@@ -290,6 +324,31 @@ def uniform(
t = TIMESTEP_MAX * (1.0 - f)
return t.to(device).unsqueeze(1).expand(-1, batch_size)
+ @staticmethod
+ def independent_uniform(
+ batch_size: int,
+ num_timesteps: int,
+ timestep_range: Union[float, Tuple[float, float]],
+ time_shift: float = 1.0,
+ device: torch.device = torch.device("cpu"),
+ generator: Optional[torch.Generator] = None,
+ ) -> torch.Tensor:
+ """Draw an independent uniform coordinate per term and sample."""
+ _require_positive_int(batch_size, "batch_size")
+ _require_positive_int(num_timesteps, "num_timesteps")
+ output_device = torch.device(device)
+ rng_device = _rng_device(generator, output_device)
+ frac_lo, frac_hi = _normalize_timestep_range(timestep_range)
+ fraction = torch.rand(
+ (num_timesteps, batch_size),
+ generator=generator,
+ device=rng_device,
+ )
+ fraction = frac_lo + fraction * (frac_hi - frac_lo)
+ if abs(time_shift - 1.0) > 1e-6:
+ fraction = time_shift * fraction / (1 + (time_shift - 1) * fraction)
+ return (TIMESTEP_MAX * (1.0 - fraction)).to(output_device)
+
@staticmethod
def discrete(
batch_size: int,
@@ -366,3 +425,13 @@ def _stratified_sample(
lower, upper = boundaries[:-1].long(), boundaries[1:].long()
rand_u = torch.rand(num_samples, generator=generator, device=rng_device).to(device)
return lower + (rand_u * (upper - lower)).long()
+
+
+def _require_positive_int(value: object, field_name: str) -> None:
+ """Require a positive exact integer for materialized sampler shapes."""
+ if type(value) is not int:
+ raise TypeError(
+ f"expected {field_name} to be int, received {type(value).__name__}: {value!r}"
+ )
+ if value < 1:
+ raise ValueError(f"expected {field_name} >= 1, received {value}")
diff --git a/tests/test_noise_schedule.py b/tests/test_noise_schedule.py
index f475f10fb..41110e4bf 100644
--- a/tests/test_noise_schedule.py
+++ b/tests/test_noise_schedule.py
@@ -17,6 +17,7 @@
from flow_factory.utils.noise_schedule import (
TIMESTEP_MAX,
+ TimeSampler,
flow_match_sigma,
validate_flow_match_coordinates,
)
@@ -95,6 +96,70 @@ def test_flow_match_sigma_rejects_non_tensor_input() -> None:
flow_match_sigma(500.0) # type: ignore[arg-type]
+@pytest.mark.parametrize(
+ "sampler_name",
+ ["independent_logit_normal_shifted", "independent_uniform"],
+)
+def test_independent_time_samplers_materialize_distinct_batch_draws(
+ sampler_name: str,
+) -> None:
+ generator = torch.Generator(device="cpu").manual_seed(123)
+ sampler = getattr(TimeSampler, sampler_name)
+
+ timesteps = sampler(
+ batch_size=4,
+ num_timesteps=3,
+ timestep_range=(0.2, 0.8),
+ generator=generator,
+ )
+
+ assert timesteps.shape == (3, 4)
+ assert timesteps.stride() != (1, 0)
+ assert torch.all(timesteps >= 200.0)
+ assert torch.all(timesteps <= 800.0)
+ assert all(torch.unique(row).numel() > 1 for row in timesteps)
+
+
+@pytest.mark.parametrize(
+ "sampler_name",
+ ["independent_logit_normal_shifted", "independent_uniform"],
+)
+def test_independent_time_samplers_are_generator_reproducible(sampler_name: str) -> None:
+ sampler = getattr(TimeSampler, sampler_name)
+ first_generator = torch.Generator(device="cpu").manual_seed(91)
+ second_generator = torch.Generator(device="cpu").manual_seed(91)
+
+ first = sampler(2, 4, 0.99, generator=first_generator)
+ second = sampler(2, 4, 0.99, generator=second_generator)
+
+ torch.testing.assert_close(first, second, rtol=0, atol=0)
+
+
+@pytest.mark.parametrize("sampler_name", ["logit_normal_shifted", "uniform"])
+def test_legacy_time_samplers_keep_shared_batch_coordinates(sampler_name: str) -> None:
+ sampler = getattr(TimeSampler, sampler_name)
+ generator = torch.Generator(device="cpu").manual_seed(321)
+
+ timesteps = sampler(4, 3, (0.2, 0.8), generator=generator)
+
+ assert timesteps.shape == (3, 4)
+ for row in timesteps:
+ torch.testing.assert_close(row, row[0].expand_as(row), rtol=0, atol=0)
+
+
+@pytest.mark.parametrize("field_name", ["batch_size", "num_timesteps"])
+@pytest.mark.parametrize("invalid", [0, -1, True, 1.0])
+def test_independent_time_samplers_validate_materialized_shape(
+ field_name: str,
+ invalid: object,
+) -> None:
+ kwargs = {"batch_size": 2, "num_timesteps": 3, field_name: invalid}
+ expected_error = TypeError if type(invalid) is not int else ValueError
+
+ with pytest.raises(expected_error):
+ TimeSampler.independent_uniform(timestep_range=0.99, **kwargs)
+
+
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
def test_flow_match_sigma_matches_cpu_at_upper_interior_on_cuda() -> None:
endpoint = torch.tensor([TIMESTEP_MAX], dtype=torch.float32)
From 526d22aa15101953739a2bbbfbb05b94a9575d4e Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:10:44 +0800
Subject: [PATCH 04/76] feat(contracts): define orthogonal training and
pipeline semantics
---
src/flow_factory/contracts/__init__.py | 85 +++
src/flow_factory/contracts/execution.py | 86 +++
src/flow_factory/contracts/model_condition.py | 97 +++
src/flow_factory/contracts/pipeline_io.py | 557 ++++++++++++++++++
tests/contracts/test_execution_contract.py | 87 +++
tests/contracts/test_pipeline_io_contract.py | 484 +++++++++++++++
6 files changed, 1396 insertions(+)
create mode 100644 src/flow_factory/contracts/__init__.py
create mode 100644 src/flow_factory/contracts/execution.py
create mode 100644 src/flow_factory/contracts/model_condition.py
create mode 100644 src/flow_factory/contracts/pipeline_io.py
create mode 100644 tests/contracts/test_execution_contract.py
create mode 100644 tests/contracts/test_pipeline_io_contract.py
diff --git a/src/flow_factory/contracts/__init__.py b/src/flow_factory/contracts/__init__.py
new file mode 100644
index 000000000..1da8d56d5
--- /dev/null
+++ b/src/flow_factory/contracts/__init__.py
@@ -0,0 +1,85 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Dependency-neutral framework contracts."""
+
+from .execution import (
+ OFFLINE_EXECUTION_CONTRACT,
+ ONLINE_EXECUTION_CONTRACT,
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ AcquisitionMode,
+ ExecutionContract,
+ FeedbackMode,
+)
+from .model_condition import (
+ FORWARD_STATE_BOUNDARY_KEYS,
+ FORWARD_STATE_OWNED_KEYS,
+ NON_MODEL_CONDITION_KEYS,
+ OFFLINE_PROVENANCE_KEYS,
+ ROLLOUT_STORAGE_KEYS,
+ TRAINER_METADATA_KEYS,
+)
+from .pipeline_io import (
+ BatchCapability,
+ DecodedMediaLike,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaLike,
+ InputMediaOrder,
+ InputMediaRule,
+ InputMediaSpec,
+ MediaFormat,
+ MediaType,
+ ModelInputLike,
+ NegativePromptPolicy,
+ OutputMediaLike,
+ OutputMediaSequence,
+ PipelineIOContract,
+ RateRequirement,
+ validate_pipeline_model_input,
+ validate_pipeline_output_candidate,
+)
+
+__all__ = [
+ "AcquisitionMode",
+ "BatchCapability",
+ "DecodedMediaLike",
+ "ExecutionContract",
+ "FeedbackMode",
+ "FORWARD_STATE_BOUNDARY_KEYS",
+ "FORWARD_STATE_OWNED_KEYS",
+ "GeometrySource",
+ "InputMediaBinding",
+ "InputMediaLike",
+ "InputMediaOrder",
+ "InputMediaRule",
+ "InputMediaSpec",
+ "MediaFormat",
+ "MediaType",
+ "ModelInputLike",
+ "NegativePromptPolicy",
+ "NON_MODEL_CONDITION_KEYS",
+ "OFFLINE_EXECUTION_CONTRACT",
+ "OFFLINE_PROVENANCE_KEYS",
+ "ONLINE_EXECUTION_CONTRACT",
+ "ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT",
+ "OutputMediaLike",
+ "OutputMediaSequence",
+ "PipelineIOContract",
+ "RateRequirement",
+ "ROLLOUT_STORAGE_KEYS",
+ "TRAINER_METADATA_KEYS",
+ "validate_pipeline_model_input",
+ "validate_pipeline_output_candidate",
+]
diff --git a/src/flow_factory/contracts/execution.py b/src/flow_factory/contracts/execution.py
new file mode 100644
index 000000000..052471f70
--- /dev/null
+++ b/src/flow_factory/contracts/execution.py
@@ -0,0 +1,86 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Dependency-neutral execution semantics for training algorithms."""
+
+from dataclasses import dataclass
+from enum import Enum
+
+
+class AcquisitionMode(str, Enum):
+ """Describe where optimization examples come from."""
+
+ GENERATION = "generation"
+ DATASET = "dataset"
+
+
+class FeedbackMode(str, Enum):
+ """Describe whether an acquisition requires runtime reward feedback."""
+
+ RUNTIME_REWARD = "runtime_reward"
+ NONE = "none"
+
+
+@dataclass(frozen=True)
+class ExecutionContract:
+ """Declare orthogonal acquisition and feedback semantics for an algorithm.
+
+ Acquisition determines only how examples enter the training kernel. Feedback
+ independently determines whether the acquired examples pass through the runtime
+ reward/advantage stage before optimization. Cycle and loader details are derived
+ runtime policy, not additional user-configurable axes.
+ """
+
+ acquisition: AcquisitionMode
+ feedback: FeedbackMode
+
+ def __post_init__(self) -> None:
+ """Require typed enum members without coercing ambiguous strings."""
+ _require_enum(self.acquisition, AcquisitionMode, "acquisition")
+ _require_enum(self.feedback, FeedbackMode, "feedback")
+
+
+def _require_enum(value: object, enum_type: type[Enum], field_name: str) -> None:
+ """Require one typed execution enum member."""
+ if not isinstance(value, enum_type):
+ raise TypeError(
+ f"expected {field_name} to be {enum_type.__name__}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+
+
+ONLINE_EXECUTION_CONTRACT = ExecutionContract(
+ acquisition=AcquisitionMode.GENERATION,
+ feedback=FeedbackMode.RUNTIME_REWARD,
+)
+
+ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT = ExecutionContract(
+ acquisition=AcquisitionMode.GENERATION,
+ feedback=FeedbackMode.NONE,
+)
+
+OFFLINE_EXECUTION_CONTRACT = ExecutionContract(
+ acquisition=AcquisitionMode.DATASET,
+ feedback=FeedbackMode.NONE,
+)
+
+
+__all__ = [
+ "AcquisitionMode",
+ "ExecutionContract",
+ "FeedbackMode",
+ "OFFLINE_EXECUTION_CONTRACT",
+ "ONLINE_EXECUTION_CONTRACT",
+ "ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT",
+]
diff --git a/src/flow_factory/contracts/model_condition.py b/src/flow_factory/contracts/model_condition.py
new file mode 100644
index 000000000..d1fa4ed00
--- /dev/null
+++ b/src/flow_factory/contracts/model_condition.py
@@ -0,0 +1,97 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Reserved model-condition field ownership shared across runtime layers.
+
+Only model-conditioning fields may cross the adapter forward boundary. The
+constants in this module are the single authority used by both online replay
+dispatch and offline output-context binding.
+"""
+
+FORWARD_STATE_BOUNDARY_KEYS = frozenset(
+ {
+ "batch",
+ "state",
+ "times",
+ "next_state",
+ "return_fields",
+ "forward_kwargs",
+ }
+)
+
+FORWARD_STATE_OWNED_KEYS = frozenset(
+ {
+ "t",
+ "t_next",
+ "latents",
+ "next_latents",
+ "compute_log_prob",
+ "return_kwargs",
+ "noise_level",
+ }
+)
+
+ROLLOUT_STORAGE_KEYS = frozenset(
+ {
+ "trajectory",
+ "timesteps",
+ "all_latents",
+ "latent_index_map",
+ "log_probs",
+ "log_prob_index_map",
+ }
+)
+
+TRAINER_METADATA_KEYS = frozenset({"advantage"})
+
+OFFLINE_PROVENANCE_KEYS = frozenset(
+ {
+ "__offline_condition_id__",
+ "condition",
+ "condition_id",
+ "condition_ids",
+ "record_id",
+ "record_ids",
+ "source",
+ "sources",
+ "source_id",
+ "source_ids",
+ "model_input",
+ "model_inputs",
+ "supervision_type",
+ "output",
+ "target_media",
+ "chosen_media",
+ "rejected_media",
+ "metadata",
+ "metadata_json",
+ }
+)
+
+NON_MODEL_CONDITION_KEYS = (
+ FORWARD_STATE_BOUNDARY_KEYS
+ | FORWARD_STATE_OWNED_KEYS
+ | ROLLOUT_STORAGE_KEYS
+ | TRAINER_METADATA_KEYS
+ | OFFLINE_PROVENANCE_KEYS
+)
+
+__all__ = [
+ "FORWARD_STATE_BOUNDARY_KEYS",
+ "FORWARD_STATE_OWNED_KEYS",
+ "NON_MODEL_CONDITION_KEYS",
+ "OFFLINE_PROVENANCE_KEYS",
+ "ROLLOUT_STORAGE_KEYS",
+ "TRAINER_METADATA_KEYS",
+]
diff --git a/src/flow_factory/contracts/pipeline_io.py b/src/flow_factory/contracts/pipeline_io.py
new file mode 100644
index 000000000..bbc15316e
--- /dev/null
+++ b/src/flow_factory/contracts/pipeline_io.py
@@ -0,0 +1,557 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Dependency-neutral declarations for model pipeline inputs and outputs."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, Protocol, runtime_checkable
+
+
+class MediaType(str, Enum):
+ """Media modalities understood by pipeline I/O contracts."""
+
+ IMAGE = "image"
+ VIDEO = "video"
+ AUDIO = "audio"
+
+
+class RateRequirement(str, Enum):
+ """Declare whether a modality-specific rate field is accepted or required."""
+
+ NOT_APPLICABLE = "not_applicable"
+ OPTIONAL = "optional"
+ REQUIRED = "required"
+
+
+class InputMediaBinding(str, Enum):
+ """Describe how input media are bound to model-facing arguments."""
+
+ GROUPED_BY_TYPE = "grouped_by_type"
+ ORDERED_REFERENCES = "ordered_references"
+
+
+class InputMediaOrder(str, Enum):
+ """Describe which input-media ordering carries semantic meaning."""
+
+ INSENSITIVE = "insensitive"
+ WITHIN_TYPE = "within_type"
+ GLOBAL = "global"
+
+
+class NegativePromptPolicy(str, Enum):
+ """Declare whether a pipeline accepts a negative prompt."""
+
+ UNSUPPORTED = "unsupported"
+ OPTIONAL = "optional"
+ REQUIRED = "required"
+
+
+class GeometrySource(str, Enum):
+ """Declare where output geometry is resolved for one pipeline operation."""
+
+ CONFIGURED = "configured"
+ INPUT_MEDIA = "input_media"
+ OUTPUT_MEDIA = "output_media"
+ PRIMARY_OUTPUT_MEDIA = "primary_output_media"
+
+
+class BatchCapability(str, Enum):
+ """Describe the media-layout uniformity supported by a pipeline operation."""
+
+ UNIFORM = "uniform"
+ RAGGED = "ragged"
+ SINGLE_SAMPLE = "single_sample"
+
+
+@runtime_checkable
+class DecodedMediaLike(Protocol):
+ """Structural boundary for decoded media without importing a dataset type."""
+
+ @property
+ def type(self) -> str:
+ """Return the public media type discriminator."""
+ ...
+
+ @property
+ def payload(self) -> Any:
+ """Return the decoded CPU-side media payload."""
+ ...
+
+ @property
+ def fps(self) -> float | None:
+ """Return source frames per second when applicable."""
+ ...
+
+ @property
+ def sample_rate(self) -> int | None:
+ """Return source samples per second when applicable."""
+ ...
+
+
+@runtime_checkable
+class InputMediaLike(Protocol):
+ """Structural media reference accepted by input-contract validation."""
+
+ @property
+ def type(self) -> str:
+ """Return the public media type discriminator."""
+ ...
+
+ @property
+ def fps(self) -> float | None:
+ """Return an optional source frame rate."""
+ ...
+
+ @property
+ def sample_rate(self) -> int | None:
+ """Return an optional source sample rate."""
+ ...
+
+
+@runtime_checkable
+class OutputMediaLike(Protocol):
+ """Structural output-media metadata accepted before payload decoding."""
+
+ @property
+ def type(self) -> str:
+ """Return the public media type discriminator."""
+ ...
+
+ @property
+ def fps(self) -> float | None:
+ """Return an optional source frame rate."""
+ ...
+
+ @property
+ def sample_rate(self) -> int | None:
+ """Return an optional source sample rate."""
+ ...
+
+
+@runtime_checkable
+class ModelInputLike(Protocol):
+ """Structural normalized input consumed by a pipeline I/O contract."""
+
+ @property
+ def prompt(self) -> str:
+ """Return the positive text prompt."""
+ ...
+
+ @property
+ def negative_prompt(self) -> str | None:
+ """Return the optional negative text prompt."""
+ ...
+
+ @property
+ def media(self) -> tuple[InputMediaLike, ...]:
+ """Return input media in public record order."""
+ ...
+
+
+@dataclass(frozen=True, slots=True)
+class MediaFormat:
+ """Declare one media modality and its rate-metadata requirements."""
+
+ type: MediaType
+ fps: RateRequirement
+ sample_rate: RateRequirement
+
+ def __post_init__(self) -> None:
+ """Validate strict field types and modality-specific rate coherence."""
+ _require_enum(self.type, MediaType, "type")
+ _require_enum(self.fps, RateRequirement, "fps")
+ _require_enum(self.sample_rate, RateRequirement, "sample_rate")
+
+ if self.type is MediaType.IMAGE:
+ if (
+ self.fps is not RateRequirement.NOT_APPLICABLE
+ or self.sample_rate is not RateRequirement.NOT_APPLICABLE
+ ):
+ raise ValueError("image media cannot declare fps or sample_rate requirements")
+ return
+ if self.type is MediaType.VIDEO:
+ if self.fps is RateRequirement.NOT_APPLICABLE:
+ raise ValueError("video media must declare fps as optional or required")
+ if self.sample_rate is not RateRequirement.NOT_APPLICABLE:
+ raise ValueError("video media cannot declare a sample_rate requirement")
+ return
+ if self.fps is not RateRequirement.NOT_APPLICABLE:
+ raise ValueError("audio media cannot declare an fps requirement")
+ if self.sample_rate is RateRequirement.NOT_APPLICABLE:
+ raise ValueError("audio media must declare sample_rate as optional or required")
+
+
+@dataclass(frozen=True, slots=True)
+class InputMediaRule:
+ """Declare the accepted count for one input media format."""
+
+ format: MediaFormat
+ min_count: int
+ max_count: int | None
+
+ def __post_init__(self) -> None:
+ """Validate strict cardinality types and bounds."""
+ _require_instance(self.format, MediaFormat, "format")
+ _require_non_negative_int(self.min_count, "min_count")
+ if self.max_count is not None:
+ _require_non_negative_int(self.max_count, "max_count")
+ if self.max_count == 0:
+ raise ValueError("max_count=0 is not canonical; omit the media rule instead")
+ if self.max_count < self.min_count:
+ raise ValueError(
+ f"expected max_count >= min_count, received "
+ f"min_count={self.min_count} and max_count={self.max_count}"
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class InputMediaSpec:
+ """Declare input-media types, counts, ordering, and argument binding."""
+
+ rules: tuple[InputMediaRule, ...]
+ binding: InputMediaBinding
+ order: InputMediaOrder
+
+ def __post_init__(self) -> None:
+ """Validate a canonical and coherent input-media declaration."""
+ _require_tuple(self.rules, InputMediaRule, "rules")
+ _require_enum(self.binding, InputMediaBinding, "binding")
+ _require_enum(self.order, InputMediaOrder, "order")
+
+ media_types = tuple(rule.format.type for rule in self.rules)
+ if len(set(media_types)) != len(media_types):
+ raise ValueError("input media rules must contain each media type at most once")
+ canonical_media_types = tuple(
+ media_type for media_type in MediaType if media_type in media_types
+ )
+ if media_types != canonical_media_types:
+ raise ValueError(
+ "input media rules must use canonical type order "
+ f"{canonical_media_types}, received {media_types}"
+ )
+ if not self.rules:
+ if self.binding is not InputMediaBinding.GROUPED_BY_TYPE:
+ raise ValueError("media-free inputs must use grouped_by_type binding")
+ if self.order is not InputMediaOrder.INSENSITIVE:
+ raise ValueError("media-free inputs must use insensitive ordering")
+ return
+ if self.binding is InputMediaBinding.ORDERED_REFERENCES:
+ if self.order is not InputMediaOrder.GLOBAL:
+ raise ValueError("ordered_references binding requires global input ordering")
+ elif self.order is InputMediaOrder.GLOBAL:
+ raise ValueError("global input ordering requires ordered_references binding")
+
+
+@dataclass(frozen=True, slots=True)
+class OutputMediaSequence:
+ """Declare the exact ordered media sequence produced by a pipeline."""
+
+ items: tuple[MediaFormat, ...]
+
+ def __post_init__(self) -> None:
+ """Validate a non-empty, immutable output-media sequence."""
+ _require_tuple(self.items, MediaFormat, "items")
+ if not self.items:
+ raise ValueError("output media sequence must contain at least one item")
+
+
+@dataclass(frozen=True, slots=True)
+class PipelineIOContract:
+ """Declare model-agnostic pipeline input and decoded-output semantics."""
+
+ input_media: InputMediaSpec
+ negative_prompt: NegativePromptPolicy
+ output_media: OutputMediaSequence
+ geometry_source: GeometrySource
+ batch_capability: BatchCapability
+
+ def __post_init__(self) -> None:
+ """Validate strict types for every pipeline I/O declaration."""
+ _require_instance(self.input_media, InputMediaSpec, "input_media")
+ _require_enum(self.negative_prompt, NegativePromptPolicy, "negative_prompt")
+ _require_instance(self.output_media, OutputMediaSequence, "output_media")
+ _require_enum(self.geometry_source, GeometrySource, "geometry_source")
+ _require_enum(self.batch_capability, BatchCapability, "batch_capability")
+ if self.geometry_source is GeometrySource.INPUT_MEDIA and not any(
+ rule.min_count > 0 for rule in self.input_media.rules
+ ):
+ raise ValueError(
+ "input_media geometry requires at least one input media rule with min_count > 0"
+ )
+
+
+def validate_pipeline_model_input(
+ model_input: ModelInputLike,
+ contract: PipelineIOContract,
+) -> None:
+ """Validate one normalized input against model-declared pipeline semantics.
+
+ The function depends only on structural protocols, so the dataset schema and
+ model adapter remain independent. Callers must run it before preprocessing;
+ otherwise an adapter may silently ignore unsupported conditioning media.
+
+ Args:
+ model_input: Structurally normalized prompt and input-media metadata.
+ contract: Adapter-owned pipeline input/output declaration.
+
+ Returns:
+ None after successful validation.
+
+ Raises:
+ TypeError: If the input or any field violates boundary types.
+ ValueError: If prompt or input media violates the pipeline contract.
+ """
+ _require_instance(contract, PipelineIOContract, "contract")
+ if not isinstance(model_input, ModelInputLike):
+ raise TypeError(
+ "expected model_input to implement ModelInputLike, received "
+ f"{type(model_input).__name__}: {model_input!r}"
+ )
+ if type(model_input.prompt) is not str:
+ raise TypeError(
+ "expected model_input.prompt to be str, received "
+ f"{type(model_input.prompt).__name__}: {model_input.prompt!r}"
+ )
+ negative_prompt = model_input.negative_prompt
+ if negative_prompt is not None and type(negative_prompt) is not str:
+ raise TypeError(
+ "expected model_input.negative_prompt to be str or None, received "
+ f"{type(negative_prompt).__name__}: {negative_prompt!r}"
+ )
+ if contract.negative_prompt is NegativePromptPolicy.UNSUPPORTED and negative_prompt is not None:
+ raise ValueError("pipeline does not support negative_prompt")
+ if contract.negative_prompt is NegativePromptPolicy.REQUIRED and negative_prompt is None:
+ raise ValueError("pipeline requires negative_prompt")
+
+ media = model_input.media
+ if type(media) is not tuple:
+ raise TypeError(
+ "expected model_input.media to be tuple, received " f"{type(media).__name__}: {media!r}"
+ )
+ rules_by_type = {rule.format.type.value: rule for rule in contract.input_media.rules}
+ counts = {media_type: 0 for media_type in rules_by_type}
+ for index, item in enumerate(media):
+ if not isinstance(item, InputMediaLike):
+ raise TypeError(
+ f"expected model_input.media[{index}] to implement InputMediaLike, "
+ f"received {type(item).__name__}: {item!r}"
+ )
+ media_type = item.type
+ if type(media_type) is not str:
+ raise TypeError(
+ f"expected model_input.media[{index}].type to be str, received "
+ f"{type(media_type).__name__}: {media_type!r}"
+ )
+ rule = rules_by_type.get(media_type)
+ if rule is None:
+ raise ValueError(
+ f"pipeline does not accept input media type {media_type!r} at index {index}; "
+ f"accepted types={tuple(rules_by_type)!r}"
+ )
+ counts[media_type] += 1
+ _validate_input_rate(item.fps, rule.format.fps, "fps", index)
+ _validate_input_rate(
+ item.sample_rate,
+ rule.format.sample_rate,
+ "sample_rate",
+ index,
+ )
+
+ for media_type, rule in rules_by_type.items():
+ count = counts[media_type]
+ if count < rule.min_count:
+ raise ValueError(
+ f"pipeline requires at least {rule.min_count} input {media_type!r} item(s), "
+ f"received {count}"
+ )
+ if rule.max_count is not None and count > rule.max_count:
+ raise ValueError(
+ f"pipeline accepts at most {rule.max_count} input {media_type!r} item(s), "
+ f"received {count}"
+ )
+
+
+def validate_pipeline_output_candidate(
+ media: tuple[OutputMediaLike, ...],
+ contract: PipelineIOContract,
+) -> None:
+ """Validate undecoded output metadata against exact pipeline semantics.
+
+ This dependency-neutral boundary lets a dataset reject incompatible target,
+ chosen, or rejected media before condition preprocessing or payload decoding.
+ Model-specific geometry remains owned by later boundaries.
+
+ Args:
+ media: Exact ordered media tuple for one output candidate.
+ contract: Adapter-owned pipeline input/output declaration.
+
+ Returns:
+ None after successful validation.
+
+ Raises:
+ TypeError: If the candidate or its metadata violates boundary types.
+ ValueError: If media order, modality, or rate violates the contract.
+ """
+ _require_instance(contract, PipelineIOContract, "contract")
+ if type(media) is not tuple:
+ raise TypeError(
+ "expected output candidate media to be tuple, received "
+ f"{type(media).__name__}: {media!r}"
+ )
+ expected_items = contract.output_media.items
+ if len(media) != len(expected_items):
+ raise ValueError(
+ "expected output candidate exact media sequence length "
+ f"{len(expected_items)}, received {len(media)}"
+ )
+ for index, (item, expected) in enumerate(zip(media, expected_items)):
+ if not isinstance(item, OutputMediaLike):
+ raise TypeError(
+ f"expected output candidate media[{index}] to implement OutputMediaLike, "
+ f"received {type(item).__name__}: {item!r}"
+ )
+ media_type = item.type
+ if type(media_type) is not str:
+ raise TypeError(
+ f"expected output candidate media[{index}].type to be str, received "
+ f"{type(media_type).__name__}: {media_type!r}"
+ )
+ if media_type != expected.type.value:
+ raise ValueError(
+ f"expected output candidate media[{index}].type {expected.type.value!r}, "
+ f"received {media_type!r}"
+ )
+ _validate_output_rate(item.fps, expected.fps, "fps", index)
+ _validate_output_rate(
+ item.sample_rate,
+ expected.sample_rate,
+ "sample_rate",
+ index,
+ )
+
+
+def _validate_input_rate(
+ value: object,
+ requirement: RateRequirement,
+ rate_name: str,
+ media_index: int,
+) -> None:
+ """Validate one normalized rate against its declared requirement."""
+ if requirement is RateRequirement.NOT_APPLICABLE:
+ if value is not None:
+ raise ValueError(
+ f"pipeline input media[{media_index}] does not accept {rate_name}, "
+ f"received {value!r}"
+ )
+ return
+ if value is None:
+ if requirement is RateRequirement.REQUIRED:
+ raise ValueError(f"pipeline input media[{media_index}] requires {rate_name}")
+ return
+ if rate_name == "fps":
+ if type(value) is not float or not math.isfinite(value) or value <= 0:
+ raise ValueError(
+ f"pipeline input media[{media_index}] requires finite positive fps, "
+ f"received {value!r}"
+ )
+ return
+ if type(value) is not int or value <= 0:
+ raise ValueError(
+ f"pipeline input media[{media_index}] requires positive integer sample_rate, "
+ f"received {value!r}"
+ )
+
+
+def _validate_output_rate(
+ value: object,
+ requirement: RateRequirement,
+ rate_name: str,
+ media_index: int,
+) -> None:
+ """Validate one undecoded output rate against its declared requirement."""
+ identifier = f"output candidate media[{media_index}].{rate_name}"
+ if requirement is RateRequirement.NOT_APPLICABLE:
+ if value is not None:
+ raise ValueError(f"expected {identifier}=None, received {value!r}")
+ return
+ if value is None:
+ if requirement is RateRequirement.REQUIRED:
+ raise ValueError(f"expected required {identifier}, received None")
+ return
+ if rate_name == "fps":
+ if type(value) is not float or not math.isfinite(value) or value <= 0:
+ raise ValueError(f"expected finite positive {identifier}, received {value!r}")
+ return
+ if type(value) is not int or value <= 0:
+ raise ValueError(f"expected positive integer {identifier}, received {value!r}")
+
+
+def _require_enum(value: object, enum_type: type[Enum], field_name: str) -> None:
+ if not isinstance(value, enum_type):
+ raise TypeError(
+ f"expected {field_name} to be {enum_type.__name__}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _require_instance(value: object, expected_type: type[object], field_name: str) -> None:
+ if type(value) is not expected_type:
+ raise TypeError(
+ f"expected {field_name} to be {expected_type.__name__}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _require_tuple(value: object, item_type: type[object], field_name: str) -> None:
+ if type(value) is not tuple:
+ raise TypeError(
+ f"expected {field_name} to be tuple, received {type(value).__name__}: {value!r}"
+ )
+ for index, item in enumerate(value):
+ _require_instance(item, item_type, f"{field_name}[{index}]")
+
+
+def _require_non_negative_int(value: object, field_name: str) -> None:
+ if type(value) is not int:
+ raise TypeError(
+ f"expected {field_name} to be int, received {type(value).__name__}: {value!r}"
+ )
+ if value < 0:
+ raise ValueError(f"expected {field_name} >= 0, received {value}")
+
+
+__all__ = [
+ "BatchCapability",
+ "DecodedMediaLike",
+ "GeometrySource",
+ "InputMediaBinding",
+ "InputMediaLike",
+ "InputMediaOrder",
+ "InputMediaRule",
+ "InputMediaSpec",
+ "MediaFormat",
+ "MediaType",
+ "ModelInputLike",
+ "NegativePromptPolicy",
+ "OutputMediaSequence",
+ "OutputMediaLike",
+ "PipelineIOContract",
+ "RateRequirement",
+ "validate_pipeline_model_input",
+ "validate_pipeline_output_candidate",
+]
diff --git a/tests/contracts/test_execution_contract.py b/tests/contracts/test_execution_contract.py
new file mode 100644
index 000000000..4aed5b2f1
--- /dev/null
+++ b/tests/contracts/test_execution_contract.py
@@ -0,0 +1,87 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for dependency-neutral algorithm execution semantics."""
+
+from dataclasses import FrozenInstanceError, fields
+
+import pytest
+
+from flow_factory.contracts.execution import (
+ OFFLINE_EXECUTION_CONTRACT,
+ ONLINE_EXECUTION_CONTRACT,
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ AcquisitionMode,
+ ExecutionContract,
+ FeedbackMode,
+)
+
+
+def test_execution_contract_has_only_orthogonal_algorithm_axes() -> None:
+ """Loader and cycle policy are runtime derivations, not duplicate config axes."""
+ assert tuple(field.name for field in fields(ExecutionContract)) == (
+ "acquisition",
+ "feedback",
+ )
+
+
+def test_predefined_contracts_distinguish_acquisition_from_feedback() -> None:
+ """Reward-free distillation remains generated while offline data is dataset-owned."""
+ assert ONLINE_EXECUTION_CONTRACT == ExecutionContract(
+ acquisition=AcquisitionMode.GENERATION,
+ feedback=FeedbackMode.RUNTIME_REWARD,
+ )
+ assert ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT == ExecutionContract(
+ acquisition=AcquisitionMode.GENERATION,
+ feedback=FeedbackMode.NONE,
+ )
+ assert OFFLINE_EXECUTION_CONTRACT == ExecutionContract(
+ acquisition=AcquisitionMode.DATASET,
+ feedback=FeedbackMode.NONE,
+ )
+
+
+def test_acquisition_and_feedback_are_independently_composable() -> None:
+ """Future dataset algorithms may opt into runtime feedback without schema changes."""
+ contract = ExecutionContract(
+ acquisition=AcquisitionMode.DATASET,
+ feedback=FeedbackMode.RUNTIME_REWARD,
+ )
+
+ assert contract.feedback is FeedbackMode.RUNTIME_REWARD
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "field_name"),
+ [
+ ({"acquisition": "generation"}, "acquisition"),
+ ({"feedback": "none"}, "feedback"),
+ ],
+)
+def test_execution_contract_rejects_untyped_strings(kwargs, field_name: str) -> None:
+ """Raw strings cannot silently enter an immutable algorithm contract."""
+ values = {
+ "acquisition": AcquisitionMode.GENERATION,
+ "feedback": FeedbackMode.NONE,
+ }
+ values.update(kwargs)
+
+ with pytest.raises(TypeError, match=field_name):
+ ExecutionContract(**values)
+
+
+def test_execution_contract_is_immutable() -> None:
+ """Algorithm semantics cannot mutate after registry resolution."""
+ with pytest.raises(FrozenInstanceError):
+ OFFLINE_EXECUTION_CONTRACT.feedback = FeedbackMode.RUNTIME_REWARD
diff --git a/tests/contracts/test_pipeline_io_contract.py b/tests/contracts/test_pipeline_io_contract.py
new file mode 100644
index 000000000..bb8684bb4
--- /dev/null
+++ b/tests/contracts/test_pipeline_io_contract.py
@@ -0,0 +1,484 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for dependency-neutral pipeline I/O declarations."""
+
+from dataclasses import FrozenInstanceError, dataclass
+
+import pytest
+
+from flow_factory.contracts import (
+ BatchCapability,
+ DecodedMediaLike,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaLike,
+ InputMediaOrder,
+ InputMediaRule,
+ InputMediaSpec,
+ MediaFormat,
+ MediaType,
+ ModelInputLike,
+ NegativePromptPolicy,
+ OutputMediaLike,
+ OutputMediaSequence,
+ PipelineIOContract,
+ RateRequirement,
+ validate_pipeline_model_input,
+ validate_pipeline_output_candidate,
+)
+
+IMAGE_FORMAT = MediaFormat(
+ type=MediaType.IMAGE,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+
+
+def _text_to_image_contract(
+ negative_prompt: NegativePromptPolicy = NegativePromptPolicy.OPTIONAL,
+) -> PipelineIOContract:
+ return PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=negative_prompt,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
+ )
+
+
+def test_contract_distinguishes_sd35_and_flux1_negative_prompt_support() -> None:
+ """Shared T2I media shapes do not hide model-specific text input policy."""
+ sd35 = _text_to_image_contract()
+ flux1 = _text_to_image_contract(NegativePromptPolicy.UNSUPPORTED)
+
+ assert sd35 != flux1
+ assert sd35.input_media.rules == ()
+ assert sd35.negative_prompt is NegativePromptPolicy.OPTIONAL
+ assert flux1.negative_prompt is NegativePromptPolicy.UNSUPPORTED
+ assert tuple(item.type for item in sd35.output_media.items) == (MediaType.IMAGE,)
+
+
+def test_contract_represents_flux1_kontext_grouped_single_image_input() -> None:
+ """Kontext adds exactly one grouped image without changing output semantics."""
+ contract = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(InputMediaRule(format=IMAGE_FORMAT, min_count=1, max_count=1),),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
+ )
+
+ assert contract.input_media.rules[0].min_count == 1
+ assert contract.input_media.rules[0].max_count == 1
+ assert contract.geometry_source is GeometrySource.CONFIGURED
+
+
+def test_contract_represents_ordered_multimodal_input_and_exact_av_output() -> None:
+ """Future ordered-reference and aligned AV pipelines remain expressible."""
+ video = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.REQUIRED,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+ audio = MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.REQUIRED,
+ )
+ contract = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(
+ InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=None),
+ InputMediaRule(format=video, min_count=0, max_count=None),
+ InputMediaRule(format=audio, min_count=0, max_count=None),
+ ),
+ binding=InputMediaBinding.ORDERED_REFERENCES,
+ order=InputMediaOrder.GLOBAL,
+ ),
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ output_media=OutputMediaSequence(items=(video, audio)),
+ geometry_source=GeometrySource.PRIMARY_OUTPUT_MEDIA,
+ batch_capability=BatchCapability.RAGGED,
+ )
+
+ assert tuple(item.type for item in contract.output_media.items) == (
+ MediaType.VIDEO,
+ MediaType.AUDIO,
+ )
+ assert contract.output_media.items[0].fps is RateRequirement.REQUIRED
+ assert contract.output_media.items[1].sample_rate is RateRequirement.REQUIRED
+
+
+def test_pipeline_contract_and_nested_values_are_frozen_and_hashable() -> None:
+ """The declaration is deeply immutable without a serialization framework."""
+ contract = _text_to_image_contract()
+
+ with pytest.raises(FrozenInstanceError):
+ contract.geometry_source = GeometrySource.OUTPUT_MEDIA # type: ignore[misc]
+ with pytest.raises(FrozenInstanceError):
+ contract.input_media.order = InputMediaOrder.GLOBAL # type: ignore[misc]
+ assert hash(contract)
+
+
+@dataclass
+class _DecodedFixture:
+ type: str
+ payload: object
+ fps: float | None
+ sample_rate: int | None
+
+
+@dataclass
+class _InputMediaFixture:
+ type: str
+ fps: float | None = None
+ sample_rate: int | None = None
+
+
+@dataclass
+class _ModelInputFixture:
+ prompt: str
+ negative_prompt: str | None = None
+ media: tuple[_InputMediaFixture, ...] = ()
+
+
+def test_decoded_media_protocol_is_structural_and_serialization_independent() -> None:
+ """Dataset-owned decoded objects need no contract inheritance or conversion."""
+ media = _DecodedFixture(type="image", payload=object(), fps=None, sample_rate=None)
+
+ assert isinstance(media, DecodedMediaLike)
+
+
+def test_model_input_protocols_are_structural_and_validate_prompt_only_contracts() -> None:
+ """Normalized dataset values need no inheritance from the contract package."""
+ model_input = _ModelInputFixture(prompt="a prompt", negative_prompt="low quality")
+
+ assert isinstance(model_input, ModelInputLike)
+ assert isinstance(_InputMediaFixture(type="image"), InputMediaLike)
+ validate_pipeline_model_input(model_input, _text_to_image_contract())
+
+
+def test_output_media_protocol_validates_undecoded_dataset_metadata() -> None:
+ """Target compatibility is provable without importing a dataset or decoding payloads."""
+ target = (_InputMediaFixture(type="image"),)
+
+ assert isinstance(target[0], OutputMediaLike)
+ validate_pipeline_output_candidate(target, _text_to_image_contract())
+
+ with pytest.raises(ValueError, match=r"expected.*type 'image'.*'video'"):
+ validate_pipeline_output_candidate(
+ (_InputMediaFixture(type="video", fps=24.0),),
+ _text_to_image_contract(),
+ )
+ with pytest.raises(ValueError, match=r"exact media sequence length 1, received 2"):
+ validate_pipeline_output_candidate(target * 2, _text_to_image_contract())
+
+
+def test_model_input_validation_rejects_unsupported_media_and_negative_prompt() -> None:
+ """Model-declared inputs fail before an adapter can silently ignore them."""
+ image_input = _ModelInputFixture(
+ prompt="conditioned",
+ media=(_InputMediaFixture(type="image"),),
+ )
+ with pytest.raises(ValueError, match="does not accept input media type 'image'"):
+ validate_pipeline_model_input(image_input, _text_to_image_contract())
+
+ negative_input = _ModelInputFixture(prompt="prompt", negative_prompt="unsupported")
+ with pytest.raises(ValueError, match="does not support negative_prompt"):
+ validate_pipeline_model_input(
+ negative_input,
+ _text_to_image_contract(NegativePromptPolicy.UNSUPPORTED),
+ )
+ with pytest.raises(ValueError, match="requires negative_prompt"):
+ validate_pipeline_model_input(
+ _ModelInputFixture(prompt="prompt"),
+ _text_to_image_contract(NegativePromptPolicy.REQUIRED),
+ )
+
+
+def test_model_input_validation_enforces_counts_and_required_rates() -> None:
+ """Cardinality and rate metadata remain adapter declarations, not algorithm logic."""
+ video_format = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.REQUIRED,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+ contract = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(InputMediaRule(format=video_format, min_count=1, max_count=1),),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.INPUT_MEDIA,
+ batch_capability=BatchCapability.UNIFORM,
+ )
+
+ with pytest.raises(ValueError, match="requires at least 1 input 'video'"):
+ validate_pipeline_model_input(_ModelInputFixture(prompt="prompt"), contract)
+ with pytest.raises(ValueError, match=r"media\[0\] requires fps"):
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="prompt",
+ media=(_InputMediaFixture(type="video"),),
+ ),
+ contract,
+ )
+ with pytest.raises(ValueError, match="accepts at most 1 input 'video'"):
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="prompt",
+ media=(
+ _InputMediaFixture(type="video", fps=24.0),
+ _InputMediaFixture(type="video", fps=30.0),
+ ),
+ ),
+ contract,
+ )
+
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="prompt",
+ media=(_InputMediaFixture(type="video", fps=24.0),),
+ ),
+ contract,
+ )
+
+
+@pytest.mark.parametrize(
+ "kwargs,match",
+ [
+ (
+ {
+ "type": "image",
+ "fps": RateRequirement.NOT_APPLICABLE,
+ "sample_rate": RateRequirement.NOT_APPLICABLE,
+ },
+ "expected type to be MediaType",
+ ),
+ (
+ {
+ "type": MediaType.IMAGE,
+ "fps": "not_applicable",
+ "sample_rate": RateRequirement.NOT_APPLICABLE,
+ },
+ "expected fps to be RateRequirement",
+ ),
+ ],
+)
+def test_media_format_rejects_raw_enum_values(kwargs: dict[str, object], match: str) -> None:
+ """Public constructors do not coerce strings into contract enums."""
+ with pytest.raises(TypeError, match=match):
+ MediaFormat(**kwargs) # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize("count", [True, 1.0, "1"])
+def test_input_media_rule_rejects_coercible_count_types(count: object) -> None:
+ """Cardinality values must be exact integers and never bools or strings."""
+ with pytest.raises(TypeError, match="expected min_count to be int"):
+ InputMediaRule(format=IMAGE_FORMAT, min_count=count, max_count=1) # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize(
+ "kwargs,match",
+ [
+ (
+ {
+ "type": MediaType.IMAGE,
+ "fps": RateRequirement.OPTIONAL,
+ "sample_rate": RateRequirement.NOT_APPLICABLE,
+ },
+ "image media cannot declare fps or sample_rate requirements",
+ ),
+ (
+ {
+ "type": MediaType.VIDEO,
+ "fps": RateRequirement.OPTIONAL,
+ "sample_rate": RateRequirement.OPTIONAL,
+ },
+ "video media cannot declare a sample_rate requirement",
+ ),
+ (
+ {
+ "type": MediaType.AUDIO,
+ "fps": RateRequirement.OPTIONAL,
+ "sample_rate": RateRequirement.OPTIONAL,
+ },
+ "audio media cannot declare an fps requirement",
+ ),
+ ],
+)
+def test_media_format_rejects_rates_from_another_modality(
+ kwargs: dict[str, object],
+ match: str,
+) -> None:
+ """Each modality rejects rate fields belonging to another modality."""
+ with pytest.raises(ValueError, match=match):
+ MediaFormat(**kwargs) # type: ignore[arg-type]
+
+
+def test_media_format_requires_an_applicable_rate_policy_for_video_and_audio() -> None:
+ """Rate-bearing modalities cannot leave their native rate unspecified."""
+ with pytest.raises(ValueError, match="video media must declare fps"):
+ MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+ with pytest.raises(ValueError, match="audio media must declare sample_rate"):
+ MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+
+
+def test_input_media_spec_requires_tuple_and_unique_media_types() -> None:
+ """Input rules are immutable and unambiguous by media type."""
+ rule = InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=1)
+ with pytest.raises(TypeError, match="expected rules to be tuple"):
+ InputMediaSpec(
+ rules=[rule], # type: ignore[arg-type]
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ )
+ with pytest.raises(ValueError, match="each media type at most once"):
+ InputMediaSpec(
+ rules=(rule, rule),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ )
+
+
+def test_input_media_rules_require_canonical_type_order() -> None:
+ """Equivalent grouped declarations have one stable ordering and hash."""
+ video = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.OPTIONAL,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+
+ with pytest.raises(ValueError, match="canonical type order"):
+ InputMediaSpec(
+ rules=(
+ InputMediaRule(format=video, min_count=0, max_count=1),
+ InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=1),
+ ),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ )
+
+
+def test_input_media_rule_rejects_noncanonical_or_inverted_bounds() -> None:
+ """A rule must accept at least one item and keep its count interval ordered."""
+ with pytest.raises(ValueError, match="max_count=0 is not canonical"):
+ InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=0)
+ with pytest.raises(ValueError, match="max_count >= min_count"):
+ InputMediaRule(format=IMAGE_FORMAT, min_count=2, max_count=1)
+
+
+@pytest.mark.parametrize(
+ "binding,order,match",
+ [
+ (
+ InputMediaBinding.ORDERED_REFERENCES,
+ InputMediaOrder.WITHIN_TYPE,
+ "ordered_references binding requires global",
+ ),
+ (
+ InputMediaBinding.GROUPED_BY_TYPE,
+ InputMediaOrder.GLOBAL,
+ "global input ordering requires ordered_references",
+ ),
+ ],
+)
+def test_input_binding_and_order_must_be_coherent(
+ binding: InputMediaBinding,
+ order: InputMediaOrder,
+ match: str,
+) -> None:
+ """Grouped arguments cannot silently lose global reference ordering."""
+ with pytest.raises(ValueError, match=match):
+ InputMediaSpec(
+ rules=(InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=None),),
+ binding=binding,
+ order=order,
+ )
+
+
+def test_media_free_input_has_one_canonical_binding_and_order() -> None:
+ """Prompt-only pipelines reject meaningless reference binding declarations."""
+ with pytest.raises(ValueError, match="media-free inputs must use grouped_by_type"):
+ InputMediaSpec(
+ rules=(),
+ binding=InputMediaBinding.ORDERED_REFERENCES,
+ order=InputMediaOrder.GLOBAL,
+ )
+ with pytest.raises(ValueError, match="media-free inputs must use insensitive"):
+ InputMediaSpec(
+ rules=(),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ )
+
+
+def test_output_media_sequence_is_non_empty_and_strictly_tuple_typed() -> None:
+ """An exact output sequence cannot be missing or represented by a mutable list."""
+ with pytest.raises(ValueError, match="at least one item"):
+ OutputMediaSequence(items=())
+ with pytest.raises(TypeError, match="expected items to be tuple"):
+ OutputMediaSequence(items=[IMAGE_FORMAT]) # type: ignore[arg-type]
+
+
+def test_pipeline_contract_rejects_raw_policy_values_and_algorithm_shape_fields() -> None:
+ """The I/O contract stays strict and excludes trajectory or latent layout concerns."""
+ with pytest.raises(TypeError, match="expected negative_prompt to be NegativePromptPolicy"):
+ PipelineIOContract(
+ input_media=_text_to_image_contract().input_media,
+ negative_prompt="optional", # type: ignore[arg-type]
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
+ )
+
+ contract_fields = PipelineIOContract.__dataclass_fields__
+ assert "trajectory_component_order" not in contract_fields
+ assert "latent_axis" not in contract_fields
+ assert "algorithm" not in contract_fields
+
+
+def test_input_media_geometry_requires_a_guaranteed_input() -> None:
+ """A conditional geometry source cannot rely on an optional-only input layout."""
+ with pytest.raises(ValueError, match="requires at least one input media rule"):
+ PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=1),),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.INPUT_MEDIA,
+ batch_capability=BatchCapability.UNIFORM,
+ )
From 4ad4fe925802ca5c813931718636ef2c1cb7be75 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:12:09 +0800
Subject: [PATCH 05/76] feat(models): add on-the-fly output state codecs
---
src/flow_factory/models/__init__.py | 19 +
src/flow_factory/models/abc.py | 337 ++++++++-
.../models/configured_image_output.py | 449 +++++++++++
src/flow_factory/models/flux/_output.py | 218 ++++++
src/flow_factory/models/output_state.py | 698 ++++++++++++++++++
src/flow_factory/models/pipeline_contracts.py | 168 +++++
src/flow_factory/models/qwen_image/_output.py | 189 +++++
tests/models/test_output_codec_numerics.py | 234 ++++++
tests/models/test_output_state.py | 585 +++++++++++++++
.../test_output_state_adapter_lifecycle.py | 471 ++++++++++++
10 files changed, 3366 insertions(+), 2 deletions(-)
create mode 100644 src/flow_factory/models/configured_image_output.py
create mode 100644 src/flow_factory/models/flux/_output.py
create mode 100644 src/flow_factory/models/output_state.py
create mode 100644 src/flow_factory/models/pipeline_contracts.py
create mode 100644 src/flow_factory/models/qwen_image/_output.py
create mode 100644 tests/models/test_output_codec_numerics.py
create mode 100644 tests/models/test_output_state.py
create mode 100644 tests/models/test_output_state_adapter_lifecycle.py
diff --git a/src/flow_factory/models/__init__.py b/src/flow_factory/models/__init__.py
index 90c2b1d32..9dffe36b0 100644
--- a/src/flow_factory/models/__init__.py
+++ b/src/flow_factory/models/__init__.py
@@ -24,6 +24,16 @@
from .latent_geometry import LatentAxes, LatentLayout, infer_latent_axes
from .loader import load_model
from .model_bundle import ModelBundle, RoutedComponentProxy
+from .output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+ OutputStateCodec,
+ validate_codec_required_components,
+ validate_encoded_output_state,
+ validate_output_candidate_batch,
+)
from .registry import (
get_model_adapter_class,
list_registered_models,
@@ -32,6 +42,15 @@
__all__ = [
# Core classes
"BaseAdapter",
+ # Offline target encoding
+ "DecodedMediaBatch",
+ "EncodedOutputState",
+ "GeometrySignature",
+ "MediaGeometrySignature",
+ "OutputStateCodec",
+ "validate_codec_required_components",
+ "validate_encoded_output_state",
+ "validate_output_candidate_batch",
# Latent geometry
"LatentAxes",
"LatentLayout",
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index 1247ee143..8e2d1bab6 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -70,6 +70,7 @@
from PIL import Image
from safetensors.torch import load_file, save_file
+from ..contracts import PipelineIOContract
from ..ema import EMAModuleWrapper
from ..hparams import *
from ..hparams.gradient_checkpointing import GradientCheckpointingSpec
@@ -110,6 +111,14 @@
)
from .latent_geometry import LatentAxes, infer_latent_axes
from .model_bundle import RoutedComponentProxy
+from .output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ OutputStateCodec,
+ validate_codec_required_components,
+ validate_encoded_output_state,
+ validate_output_candidate_batch,
+)
from .precision import (
build_component_load_dtype_kwargs,
cast_module_role_dtypes,
@@ -222,6 +231,11 @@ class BaseAdapter(ABC):
preprocess_cache_fields: ClassVar[frozenset[str]] = frozenset()
preprocess_cache_version: ClassVar[str] = ""
trajectory_component_order: ClassVar[Tuple[str, ...]] = ("latent",)
+ pipeline_io_contract: ClassVar[Optional[PipelineIOContract]] = None
+ # A non-empty explanation means that this adapter is intentionally online-only
+ # for now. Offline trainer loading surfaces it before model weights are loaded,
+ # while online algorithms may continue to construct and use the adapter.
+ output_state_codec_unavailable_reason: ClassVar[Optional[str]] = None
flow_velocity_direction: ClassVar[Literal["noise", "data"]] = "noise"
# Resolution-invariant latent axis roles for the model-agnostic latent state
@@ -244,6 +258,8 @@ class BaseAdapter(ABC):
# name. Overriding one would silently bypass that contract, so subclasses are
# rejected at class creation instead of at training time.
_BOUNDARY_OWNING_METHODS: ClassVar[Tuple[str, ...]] = (
+ "encode_output_state",
+ "decode_output_state",
"forward_state",
"reduce_component_latent_values",
"reduce_latent_values",
@@ -253,11 +269,19 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
for name in BaseAdapter._BOUNDARY_OWNING_METHODS:
if name in cls.__dict__:
+ if name == "encode_output_state":
+ override_hint = (
+ "Provide build_output_state_codec() and "
+ "_validate_encoded_output_geometry() instead."
+ )
+ elif name == "decode_output_state":
+ override_hint = "Override the protected hook _decode_output_state instead."
+ else:
+ override_hint = f"Override the protected hook _{name} instead."
raise TypeError(
f"adapter {cls.__name__} must not override BaseAdapter.{name}: it owns a "
f"shared contract that an override would bypass ({name} validates its "
- f"arguments and its result on behalf of every caller). Override the "
- f"protected hook _{name} instead."
+ f"arguments and its result on behalf of every caller). {override_hint}"
)
def __init__(self, config: Arguments, accelerator: Accelerator):
@@ -305,6 +329,13 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
self.model_args.target_components
)
+ # Build target-media encoding only after load-dtype policy, component runtime,
+ # scheduler group, and target-name canonicalization are established. The codec
+ # declaration is immutable lifecycle metadata; it must not materialize, load,
+ # move, or mutate component dtypes.
+ self._output_state_codec = self._build_output_state_codec_declaration()
+ self._output_state_encoding_modules = self._validate_output_state_codec_lifecycle()
+
# Cache target module mapping
self.target_module_map = self._init_target_module_map()
@@ -415,6 +446,308 @@ def cast_latent_state(
return state
return LatentState(components, active_masks=state.active_masks)
+ # ============================ Output-State Encoding ============================
+ def _build_output_state_codec_declaration(self) -> Optional[OutputStateCodec]:
+ """Build codec metadata without changing component materialization or overrides."""
+ materialized_before = tuple(self.component_runtime.materialized_component_names)
+ overrides_before = tuple(self.component_runtime.override_components)
+ codec = self.build_output_state_codec()
+ materialized_after = tuple(self.component_runtime.materialized_component_names)
+ overrides_after = tuple(self.component_runtime.override_components)
+ if materialized_after != materialized_before or overrides_after != overrides_before:
+ raise RuntimeError(
+ f"adapter {type(self).__name__}.build_output_state_codec() must be "
+ "declaration-only and cannot materialize or replace components: "
+ f"materialized_before={materialized_before}, "
+ f"materialized_after={materialized_after}, "
+ f"overrides_before={overrides_before}, overrides_after={overrides_after}"
+ )
+ return codec
+
+ @property
+ def output_state_codec(self) -> Optional[OutputStateCodec]:
+ """Return the immutable codec selected during adapter construction.
+
+ Returns:
+ Adapter-owned output codec, or ``None`` for online-only adapters.
+ """
+ return self._output_state_codec
+
+ @property
+ def output_state_encoding_modules(self) -> Tuple[str, ...]:
+ """Return validated component names required for target-media encoding.
+
+ The caller owns component device staging. Keeping this declaration separate
+ from :meth:`encode_output_state` prevents a per-batch encode from implicitly
+ moving or offloading modules behind the trainer's back.
+
+ Returns:
+ Ordered runtime component names required for target encoding.
+ """
+ return self._output_state_encoding_modules
+
+ def build_output_state_codec(self) -> Optional[OutputStateCodec]:
+ """Build the adapter-owned target-media codec, if offline training is supported.
+
+ The component runtime, canonical scheduler, and scheduler group are available
+ before this hook runs. This hook declares lifecycle metadata only: it must not
+ materialize, load, move, replace, or mutate the dtype of any model component.
+ Online-only adapters retain the default ``None``.
+
+ Returns:
+ Adapter-owned output codec, or ``None`` when offline output is unsupported.
+ """
+ return None
+
+ @classmethod
+ def _validated_output_state_codec_unavailable_reason(cls) -> Optional[str]:
+ """Return a normalized offline-codec blocker declared by the adapter."""
+ reason = cls.output_state_codec_unavailable_reason
+ if reason is None:
+ return None
+ if not isinstance(reason, str) or not reason.strip():
+ raise TypeError(
+ f"adapter {cls.__name__}.output_state_codec_unavailable_reason must be "
+ f"a non-empty string or None, received {type(reason).__name__}: {reason!r}"
+ )
+ return reason.strip()
+
+ @classmethod
+ def validate_offline_output_capability(cls) -> None:
+ """Fail before model loading unless this adapter can encode offline targets.
+
+ A concrete codec still validates its realized components during adapter
+ construction. This class-level check covers declarations that can be proven
+ without downloading weights or allocating accelerator memory.
+
+ Returns:
+ None after successful static capability validation.
+
+ Raises:
+ NotImplementedError: If the adapter declares an actionable offline blocker.
+ TypeError: If the contract, codec builder, or geometry hook is missing.
+ """
+ reason = cls._validated_output_state_codec_unavailable_reason()
+ if reason is not None:
+ raise NotImplementedError(
+ f"offline output-state encoding is unavailable for adapter "
+ f"{cls.__name__}: {reason}"
+ )
+ contract = cls.pipeline_io_contract
+ if not isinstance(contract, PipelineIOContract):
+ raise TypeError(
+ f"offline training requires adapter {cls.__name__} to declare a "
+ f"PipelineIOContract, received {type(contract).__name__}: {contract!r}"
+ )
+ if cls.build_output_state_codec is BaseAdapter.build_output_state_codec:
+ raise TypeError(
+ f"offline training requires adapter {cls.__name__} to provide an "
+ "output-state codec through build_output_state_codec()"
+ )
+ if cls._validate_encoded_output_geometry is BaseAdapter._validate_encoded_output_geometry:
+ raise TypeError(
+ f"offline training requires adapter {cls.__name__} to override "
+ "_validate_encoded_output_geometry()"
+ )
+
+ def _validate_output_state_codec_lifecycle(self) -> Tuple[str, ...]:
+ """Validate the adapter's pipeline contract and codec declaration."""
+ unavailable_reason = type(self)._validated_output_state_codec_unavailable_reason()
+ contract = self.pipeline_io_contract
+ if contract is not None and not isinstance(contract, PipelineIOContract):
+ raise TypeError(
+ f"adapter {type(self).__name__} expected pipeline_io_contract to be "
+ f"PipelineIOContract or None, received {type(contract).__name__}: {contract!r}"
+ )
+
+ codec = self.output_state_codec
+ if codec is None:
+ return ()
+ if unavailable_reason is not None:
+ raise ValueError(
+ f"adapter {type(self).__name__} built an output-state codec while declaring "
+ "output_state_codec_unavailable_reason; remove the stale blocker declaration"
+ )
+ if contract is None:
+ raise ValueError(
+ f"adapter {type(self).__name__} built an output-state codec without declaring "
+ "pipeline_io_contract"
+ )
+ return validate_codec_required_components(
+ codec,
+ tuple(self.component_runtime.declared_component_names),
+ )
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Encode decoded targets through the adapter-owned validated boundary.
+
+ Args:
+ media_batch: Exact output-media sequence for every batch sample.
+ condition: Model-input condition for the same batch.
+ generator: Optional deterministic generator used by stochastic encoders.
+
+ Returns:
+ Detached clean output state using the adapter's latent-storage policy.
+
+ Raises:
+ NotImplementedError: If the adapter declares a known codec blocker.
+ RuntimeError: If the adapter does not expose the complete offline codec seam.
+ TypeError: If condition or generator has the wrong boundary type.
+ """
+ unavailable_reason = type(self)._validated_output_state_codec_unavailable_reason()
+ if unavailable_reason is not None:
+ raise NotImplementedError(
+ f"offline output-state encoding is unavailable for adapter "
+ f"{type(self).__name__}: {unavailable_reason}"
+ )
+ contract = self.pipeline_io_contract
+ if contract is None:
+ raise RuntimeError(
+ f"adapter {type(self).__name__} cannot encode output state because it does not "
+ "declare pipeline_io_contract"
+ )
+ codec = self.output_state_codec
+ if codec is None:
+ raise RuntimeError(
+ f"adapter {type(self).__name__} declares pipeline_io_contract but does not "
+ "provide an output-state codec through build_output_state_codec()"
+ )
+ if not isinstance(condition, Mapping):
+ raise TypeError(
+ "expected output-state condition to be Mapping[str, Any], "
+ f"received {type(condition).__name__}: {condition!r}"
+ )
+ if generator is not None and not isinstance(generator, torch.Generator):
+ raise TypeError(
+ "expected output-state generator to be torch.Generator or None, "
+ f"received {type(generator).__name__}: {generator!r}"
+ )
+
+ validated_media = validate_output_candidate_batch(media_batch, contract)
+ with torch.no_grad():
+ encoded = codec.encode_output_state(
+ validated_media,
+ condition,
+ generator,
+ )
+
+ encoded = validate_encoded_output_state(
+ encoded,
+ contract=contract,
+ expected_component_order=self.trajectory_component_order,
+ expected_batch_size=len(validated_media),
+ device=self.device,
+ )
+
+ # Offline targets are trajectory states too. Apply the same storage boundary
+ # as online rollout after first proving that the codec returned detached state;
+ # casting before validation could accidentally hide an attached source tensor.
+ clean_state = self.cast_latent_state(encoded.clean_state)
+ if clean_state is not encoded.clean_state:
+ encoded = EncodedOutputState(
+ clean_state=clean_state,
+ forward_context=encoded.forward_context,
+ decode_context=encoded.decode_context,
+ geometry_signatures=encoded.geometry_signatures,
+ )
+ encoded = validate_encoded_output_state(
+ encoded,
+ contract=contract,
+ expected_component_order=self.trajectory_component_order,
+ expected_batch_size=len(validated_media),
+ device=self.device,
+ )
+
+ self._validate_encoded_output_geometry(validated_media, condition, encoded)
+ return encoded
+
+ def decode_output_state(
+ self,
+ encoded: EncodedOutputState,
+ *,
+ output_type: Literal["pil", "pt", "np"] = "pil",
+ ) -> Any:
+ """Decode one encoded offline state through the adapter's existing decoder.
+
+ ``decode_context`` may contain geometry retained only for validation as well as
+ kwargs required by a particular decoder. This wrapper forwards only names accepted
+ by ``decode_latents`` and supplies the requested output type when that decoder exposes
+ the standard ``output_type`` argument.
+
+ Args:
+ encoded: Validated single-component output state produced by this adapter.
+ output_type: Existing decoder output representation.
+
+ Returns:
+ Model-specific decoded image or video batch.
+
+ Raises:
+ TypeError: If ``encoded`` or ``output_type`` has the wrong boundary type.
+ ValueError: If the state cannot be represented by the legacy single-latent decoder.
+ """
+ if not isinstance(encoded, EncodedOutputState):
+ raise TypeError(
+ "expected encoded output state to be EncodedOutputState, "
+ f"received {type(encoded).__name__}: {encoded!r}"
+ )
+ if type(output_type) is not str:
+ raise TypeError(
+ "expected output_type to be str, "
+ f"received {type(output_type).__name__}: {output_type!r}"
+ )
+ if output_type not in ("pil", "pt", "np"):
+ raise ValueError(
+ "expected output_type in ('pil', 'pt', 'np'), " f"received {output_type!r}"
+ )
+ return self._decode_output_state(encoded, output_type=output_type)
+
+ def _decode_output_state(
+ self,
+ encoded: EncodedOutputState,
+ *,
+ output_type: Literal["pil", "pt", "np"],
+ ) -> Any:
+ """Route the default single-component state through ``decode_latents``."""
+ if encoded.clean_state.component_names != ("latent",):
+ raise ValueError(
+ "default _decode_output_state requires exactly one 'latent' component; "
+ "multi-component adapters must override the protected hook, received "
+ f"{encoded.clean_state.component_names}"
+ )
+ decode_kwargs = filter_kwargs(
+ self.decode_latents,
+ **dict(encoded.decode_context),
+ output_type=output_type,
+ )
+ return self.decode_latents(
+ encoded.clean_state.components["latent"],
+ **decode_kwargs,
+ )
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Validate codec geometry against adapter-owned input/configuration facts.
+
+ Generic validation can prove that signatures are internally coherent, but it
+ cannot prove that self-reported dimensions agree with configured geometry or
+ input-media-derived constraints. Every adapter that supplies a codec must own
+ that model-specific comparison explicitly.
+ """
+ raise NotImplementedError(
+ f"adapter {type(self).__name__} provides an output-state codec but must override "
+ "_validate_encoded_output_geometry() to validate geometry signatures against "
+ f"geometry_source={self.pipeline_io_contract.geometry_source.value!r}"
+ )
+
# ============================== Loading Components ==============================
@abstractmethod
def load_pipeline(self) -> DiffusionPipeline:
diff --git a/src/flow_factory/models/configured_image_output.py b/src/flow_factory/models/configured_image_output.py
new file mode 100644
index 000000000..39b75e568
--- /dev/null
+++ b/src/flow_factory/models/configured_image_output.py
@@ -0,0 +1,449 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Shared boundary mechanics for configured-resolution image output codecs.
+
+This module deliberately does not implement one universal VAE encoding recipe.
+Diffusers image families use different posterior normalization, latent ranks,
+packing layouts, and position metadata. The codec owns only the common decoded
+media and geometry boundary; each adapter still implements its exact official
+tensor conversion in ``_encode_output_images``.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping
+from dataclasses import dataclass
+from numbers import Real
+from typing import Any, ClassVar, Literal, Optional, Tuple
+
+import torch
+from PIL import Image
+
+from ..contracts import GeometrySource, MediaType
+from ..samples import LatentState
+from .output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+ OutputStateCodec,
+)
+
+
+@dataclass(frozen=True, slots=True)
+class EncodedImageTensor:
+ """Return one adapter-specific target tensor and its model/decode context."""
+
+ latents: torch.Tensor
+ forward_context: Mapping[str, Any]
+ decode_context: Mapping[str, Any]
+
+
+@dataclass(frozen=True, slots=True)
+class ConfiguredImageOutputCodec:
+ """Encode one configured-resolution target image per sample on demand."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("vae",)
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Validate, preprocess, and delegate family-specific target encoding.
+
+ Args:
+ media_batch: Decoded single-image candidate for every sample.
+ condition: Model condition associated with the same samples.
+ generator: Optional generator accepted by adapter-specific encoders.
+
+ Returns:
+ Detached clean latent state and its forward/decode context.
+ """
+ height, width = self.adapter._configured_output_geometry()
+ images = self._extract_images(media_batch)
+ pixel_values = self.adapter._preprocess_output_images(images, height, width)
+ self._validate_pixel_values(pixel_values, len(images), height, width)
+
+ vae = self.adapter.vae
+ vae_dtype = getattr(vae, "dtype", None)
+ if not isinstance(vae_dtype, torch.dtype) or not vae_dtype.is_floating_point:
+ raise TypeError(
+ f"{type(self.adapter).__name__} output codec expected VAE to expose a "
+ f"floating dtype, received {vae_dtype!r}"
+ )
+ pixel_values = pixel_values.to(device=self.adapter.device, dtype=vae_dtype)
+ encoded = self.adapter._encode_output_images(
+ pixel_values,
+ condition,
+ generator,
+ )
+ if not isinstance(encoded, EncodedImageTensor):
+ raise TypeError(
+ f"{type(self.adapter).__name__}._encode_output_images must return "
+ f"EncodedImageTensor, received {type(encoded).__name__}"
+ )
+ if not isinstance(encoded.latents, torch.Tensor):
+ raise TypeError(
+ f"{type(self.adapter).__name__} output image latents must be torch.Tensor, "
+ f"received {type(encoded.latents).__name__}"
+ )
+
+ decode_context = dict(encoded.decode_context)
+ for name, value in (("height", height), ("width", width)):
+ existing = decode_context.get(name, value)
+ if type(existing) is not int or existing != value:
+ raise ValueError(
+ f"{type(self.adapter).__name__} output codec decode_context {name!r} "
+ f"must equal configured value {value}, received {existing!r}"
+ )
+ decode_context[name] = value
+
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=height,
+ width=width,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=LatentState({"latent": encoded.latents}),
+ forward_context=encoded.forward_context,
+ decode_context=decode_context,
+ geometry_signatures=tuple(signature for _ in images),
+ )
+
+ @staticmethod
+ def _extract_images(media_batch: DecodedMediaBatch) -> list[Image.Image]:
+ """Extract the exact single PIL image owned by every output sample."""
+ images: list[Image.Image] = []
+ for sample_index, candidate in enumerate(media_batch):
+ if len(candidate) != 1:
+ raise ValueError(
+ "configured image output codec expected one image per sample, "
+ f"received {len(candidate)} for sample {sample_index}"
+ )
+ payload = candidate[0].payload
+ if not isinstance(payload, Image.Image):
+ raise TypeError(
+ "configured image output codec expected decoded PIL.Image targets, "
+ f"received {type(payload).__name__} for sample {sample_index}"
+ )
+ images.append(payload)
+ return images
+
+ @staticmethod
+ def _validate_pixel_values(
+ pixel_values: object,
+ batch_size: int,
+ height: int,
+ width: int,
+ ) -> None:
+ """Require the common image processor boundary to preserve B/H/W."""
+ if not isinstance(pixel_values, torch.Tensor):
+ raise TypeError(
+ "image_processor.preprocess expected torch.Tensor output, "
+ f"received {type(pixel_values).__name__}"
+ )
+ if pixel_values.ndim != 4:
+ raise ValueError(
+ "image_processor.preprocess expected rank-4 BCHW output, "
+ f"received shape {tuple(pixel_values.shape)}"
+ )
+ expected = (batch_size, height, width)
+ received = (pixel_values.shape[0], pixel_values.shape[-2], pixel_values.shape[-1])
+ if received != expected:
+ raise ValueError(
+ "image_processor.preprocess changed configured target geometry: "
+ f"expected batch/height/width {expected}, received {received}"
+ )
+
+
+class ConfiguredImageOutputAdapterMixin:
+ """Supply the common codec lifecycle for configured-resolution image models."""
+
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Build an on-the-fly image codec after validating static declarations.
+
+ Returns:
+ Configured-resolution output codec bound to this adapter.
+
+ Raises:
+ TypeError: If no pipeline contract is declared.
+ ValueError: If output media or geometry ownership is incompatible.
+ """
+ contract = self.pipeline_io_contract
+ if contract is None:
+ raise TypeError(
+ f"adapter {type(self).__name__} must declare pipeline_io_contract before "
+ "building a configured image output codec"
+ )
+ if contract.geometry_source is not GeometrySource.CONFIGURED:
+ raise ValueError(
+ f"adapter {type(self).__name__} configured image codec requires "
+ f"geometry_source='configured', received {contract.geometry_source.value!r}"
+ )
+ output_types = tuple(item.type for item in contract.output_media.items)
+ if output_types != (MediaType.IMAGE,):
+ raise ValueError(
+ f"adapter {type(self).__name__} configured image codec requires exactly one "
+ f"image output, received {output_types}"
+ )
+ self._configured_output_geometry()
+ return ConfiguredImageOutputCodec(self)
+
+ def _configured_output_geometry(self) -> Tuple[int, int]:
+ """Return positive configured H/W aligned to the adapter's latent grid."""
+ geometry = []
+ for name in ("height", "width"):
+ value = getattr(self.training_args, name, None)
+ if type(value) is not int:
+ raise TypeError(
+ f"{type(self).__name__} output geometry expected training_args.{name} "
+ f"to be int, received {type(value).__name__}: {value!r}"
+ )
+ if value <= 0:
+ raise ValueError(
+ f"{type(self).__name__} output geometry expected training_args.{name} > 0, "
+ f"received {value}"
+ )
+ geometry.append(value)
+
+ multiple = self._output_geometry_multiple()
+ if type(multiple) is not int or multiple <= 0:
+ raise ValueError(
+ f"{type(self).__name__}._output_geometry_multiple must return a positive int, "
+ f"received {multiple!r}"
+ )
+ for name, value in zip(("height", "width"), geometry):
+ if value % multiple:
+ raise ValueError(
+ f"{type(self).__name__} output geometry expected training_args.{name} "
+ f"to be divisible by {multiple}, received {value}"
+ )
+ return geometry[0], geometry[1]
+
+ def _output_geometry_multiple(self) -> int:
+ """Return the exact pixel-grid multiple imposed by VAE packing."""
+ return 1
+
+ def _preprocess_output_images(
+ self,
+ images: list[Image.Image],
+ height: int,
+ width: int,
+ ) -> torch.Tensor:
+ """Run the pipeline-owned image processor at configured geometry."""
+ return self.pipeline.image_processor.preprocess(
+ images,
+ height=height,
+ width=width,
+ )
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Prove codec signatures and decode geometry match configured H/W."""
+ height, width = self._configured_output_geometry()
+ expected_signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=height,
+ width=width,
+ ),
+ )
+ )
+ if len(encoded.geometry_signatures) != len(media_batch):
+ raise ValueError(
+ f"{type(self).__name__} expected one output geometry signature per target "
+ f"sample, received {len(encoded.geometry_signatures)} for {len(media_batch)}"
+ )
+ for sample_index, signature in enumerate(encoded.geometry_signatures):
+ if signature != expected_signature:
+ raise ValueError(
+ f"{type(self).__name__} encoded output geometry disagrees with configured "
+ f"height/width {(height, width)} for sample {sample_index}: {signature!r}"
+ )
+ for name, value in (("height", height), ("width", width)):
+ if encoded.decode_context.get(name) != value:
+ raise ValueError(
+ f"{type(self).__name__} decode_context {name!r} must equal configured "
+ f"value {value}, received {encoded.decode_context.get(name)!r}"
+ )
+ self._validate_output_image_context(media_batch, condition, encoded)
+
+ def _validate_output_image_context(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Allow an adapter to validate family-specific IDs or shape metadata."""
+ del media_batch, condition, encoded
+
+
+VAELatentSampleMode = Literal["sample", "argmax"]
+
+
+def retrieve_vae_latents(
+ encoder_output: Any,
+ *,
+ sample_mode: VAELatentSampleMode,
+ generator: Optional[torch.Generator] = None,
+ source: str,
+) -> torch.Tensor:
+ """Select sampled or argmax latents from one VAE encoder output.
+
+ Args:
+ encoder_output: VAE output exposing direct latents or a posterior distribution.
+ sample_mode: Explicit posterior selection matching the official pipeline role.
+ generator: Generator forwarded unchanged to posterior sampling.
+ source: Model-specific identifier included in validation errors.
+
+ Returns:
+ Selected latent tensor.
+
+ Raises:
+ TypeError: If the selection or encoder surface is invalid.
+ """
+ if type(sample_mode) is not str:
+ raise TypeError(
+ f"{source} expected sample_mode to be str, "
+ f"received {type(sample_mode).__name__}: {sample_mode!r}"
+ )
+ if sample_mode not in ("sample", "argmax"):
+ raise ValueError(
+ f"{source} expected sample_mode in ('sample', 'argmax'), " f"received {sample_mode!r}"
+ )
+ if generator is not None and not isinstance(generator, torch.Generator):
+ raise TypeError(
+ f"{source} expected generator to be torch.Generator or None, "
+ f"received {type(generator).__name__}: {generator!r}"
+ )
+
+ direct_latents = getattr(encoder_output, "latents", None)
+ if isinstance(direct_latents, torch.Tensor):
+ return direct_latents
+ latent_dist = getattr(encoder_output, "latent_dist", None)
+ if latent_dist is None and isinstance(encoder_output, (tuple, list)):
+ if len(encoder_output) != 1:
+ raise TypeError(
+ f"{source} expected a single VAE encoder output, received {len(encoder_output)}"
+ )
+ first_output = encoder_output[0]
+ if isinstance(first_output, torch.Tensor):
+ return first_output
+ direct_latents = getattr(first_output, "latents", None)
+ if isinstance(direct_latents, torch.Tensor):
+ return direct_latents
+ latent_dist = getattr(first_output, "latent_dist", first_output)
+ if latent_dist is None and (
+ getattr(encoder_output, "sample", None) is not None
+ or getattr(encoder_output, "mode", None) is not None
+ ):
+ latent_dist = encoder_output
+
+ if sample_mode == "sample":
+ sample = getattr(latent_dist, "sample", None)
+ if not callable(sample):
+ raise TypeError(f"{source} expected VAE posterior with callable sample()")
+ latents = sample(generator=generator)
+ else:
+ mode = getattr(latent_dist, "mode", None)
+ latents = mode() if callable(mode) else mode
+ if not isinstance(latents, torch.Tensor):
+ raise TypeError(
+ f"{source} expected VAE posterior {sample_mode!r} result to be torch.Tensor, "
+ f"received {type(latents).__name__}"
+ )
+ return latents
+
+
+def encode_shift_scale_vae_image(
+ adapter: Any,
+ pixel_values: torch.Tensor,
+ *,
+ sample_mode: VAELatentSampleMode,
+ generator: Optional[torch.Generator] = None,
+ source: Optional[str] = None,
+) -> torch.Tensor:
+ """Apply explicit posterior selection and Diffusers shift/scale normalization.
+
+ This is deliberately role-neutral: an adapter may call it for an input
+ condition or for an offline target. The orchestration layer still owns cache
+ policy, geometry, packing, and model-specific forward metadata.
+
+ Args:
+ adapter: Adapter exposing the canonical VAE.
+ pixel_values: Preprocessed BCHW pixels on the VAE device and dtype.
+ sample_mode: Explicit posterior selection for the calling pipeline role.
+ generator: Generator forwarded unchanged when ``sample_mode='sample'``.
+ source: Optional model-specific identifier included in validation errors.
+
+ Returns:
+ Shift/scale-normalized VAE latents.
+ """
+ source = source or f"{type(adapter).__name__} VAE encode"
+ vae = adapter.vae
+ latents = retrieve_vae_latents(
+ vae.encode(pixel_values),
+ sample_mode=sample_mode,
+ generator=generator,
+ source=source,
+ )
+ shift_factor, scaling_factor = _shift_scale_factors(vae, source=source)
+ return (latents - shift_factor) * scaling_factor
+
+
+def _shift_scale_factors(vae: Any, *, source: str) -> Tuple[float, float]:
+ """Validate the scalar latent normalization declared by a Diffusers VAE."""
+ config = getattr(vae, "config", None)
+ shift_factor = getattr(config, "shift_factor", None)
+ scaling_factor = getattr(config, "scaling_factor", None)
+ for name, value in (
+ ("shift_factor", shift_factor),
+ ("scaling_factor", scaling_factor),
+ ):
+ if isinstance(value, bool) or not isinstance(value, Real):
+ raise TypeError(
+ f"{source} expected numeric VAE config {name}, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ if not math.isfinite(float(value)):
+ raise ValueError(f"{source} expected finite VAE config {name}, received {value!r}")
+ if scaling_factor <= 0:
+ raise ValueError(
+ f"{source} expected VAE config scaling_factor > 0, received {scaling_factor!r}"
+ )
+ return float(shift_factor), float(scaling_factor)
+
+
+__all__ = [
+ "ConfiguredImageOutputAdapterMixin",
+ "ConfiguredImageOutputCodec",
+ "EncodedImageTensor",
+ "VAELatentSampleMode",
+ "encode_shift_scale_vae_image",
+ "retrieve_vae_latents",
+]
diff --git a/src/flow_factory/models/flux/_output.py b/src/flow_factory/models/flux/_output.py
new file mode 100644
index 000000000..67cb7d954
--- /dev/null
+++ b/src/flow_factory/models/flux/_output.py
@@ -0,0 +1,218 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Role-neutral VAE encoding shared by the FLUX pipeline variants."""
+
+from __future__ import annotations
+
+import math
+from numbers import Real
+from typing import Any, List, Optional, Tuple
+
+import torch
+
+from ..configured_image_output import (
+ EncodedImageTensor,
+ VAELatentSampleMode,
+ encode_shift_scale_vae_image,
+ retrieve_vae_latents,
+)
+
+
+def encode_flux1_vae_image(
+ adapter: Any,
+ pixel_values: torch.Tensor,
+ *,
+ sample_mode: VAELatentSampleMode,
+ generator: Optional[torch.Generator] = None,
+) -> torch.Tensor:
+ """Apply explicit posterior selection shared by FLUX.1 roles.
+
+ Args:
+ adapter: FLUX.1 adapter exposing the canonical VAE.
+ pixel_values: Preprocessed BCHW image tensor.
+ sample_mode: ``sample`` for targets or the official condition selection.
+ generator: Generator forwarded unchanged for posterior sampling.
+
+ Returns:
+ Shifted and scaled convolutional VAE latents.
+ """
+ return encode_shift_scale_vae_image(
+ adapter,
+ pixel_values,
+ sample_mode=sample_mode,
+ generator=generator,
+ )
+
+
+def encode_flux2_output_images(
+ adapter: Any,
+ pixel_values: torch.Tensor,
+ generator: Optional[torch.Generator],
+) -> EncodedImageTensor:
+ """Sample the FLUX.2 target posterior, normalize, and pack tokens.
+
+ Args:
+ adapter: FLUX.2 adapter exposing pipeline packing primitives.
+ pixel_values: Preprocessed BCHW target images.
+ generator: Generator forwarded unchanged to target posterior sampling.
+
+ Returns:
+ Packed clean latents with their position identifiers.
+ """
+ latents = encode_flux2_vae_image(
+ adapter,
+ pixel_values,
+ sample_mode="sample",
+ generator=generator,
+ )
+ latent_ids = adapter.pipeline._prepare_latent_ids(latents).to(adapter.device)
+ packed = adapter.pipeline._pack_latents(latents)
+ return EncodedImageTensor(
+ latents=packed,
+ forward_context={"latent_ids": latent_ids},
+ decode_context={"latent_ids": latent_ids},
+ )
+
+
+def encode_flux2_vae_image(
+ adapter: Any,
+ pixel_values: torch.Tensor,
+ *,
+ sample_mode: VAELatentSampleMode,
+ generator: Optional[torch.Generator] = None,
+) -> torch.Tensor:
+ """Apply the exact FLUX.2 transform shared by condition and target roles.
+
+ Args:
+ adapter: FLUX.2 adapter exposing the canonical VAE and patchify primitive.
+ pixel_values: Preprocessed BCHW image tensor.
+ sample_mode: Explicit posterior selection for the calling pipeline role.
+ generator: Generator forwarded unchanged for posterior sampling.
+
+ Returns:
+ Patchified and BatchNorm-normalized convolutional latents.
+ """
+ if pixel_values.ndim != 4:
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.2 VAE expected BCHW input, "
+ f"received shape {tuple(pixel_values.shape)}"
+ )
+ vae = adapter.vae
+ latents = retrieve_vae_latents(
+ vae.encode(pixel_values),
+ sample_mode=sample_mode,
+ generator=generator,
+ source=f"{type(adapter).__name__} FLUX.2 VAE encode",
+ )
+ latents = adapter.pipeline._patchify_latents(latents)
+ if latents.ndim != 4:
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.2 patchify expected BCHW output, "
+ f"received shape {tuple(latents.shape)}"
+ )
+
+ batch_norm = getattr(vae, "bn", None)
+ running_mean = getattr(batch_norm, "running_mean", None)
+ running_var = getattr(batch_norm, "running_var", None)
+ if not isinstance(running_mean, torch.Tensor) or not isinstance(running_var, torch.Tensor):
+ raise TypeError(
+ f"{type(adapter).__name__} FLUX.2 VAE must expose BatchNorm running_mean/running_var"
+ )
+ channels = latents.shape[1]
+ if running_mean.ndim != 1 or running_var.ndim != 1:
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.2 BatchNorm statistics must be rank 1, "
+ f"received mean={tuple(running_mean.shape)}, var={tuple(running_var.shape)}"
+ )
+ if running_mean.numel() != channels or running_var.numel() != channels:
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.2 BatchNorm expected {channels} values, "
+ f"received mean={running_mean.numel()}, var={running_var.numel()}"
+ )
+ eps = getattr(getattr(vae, "config", None), "batch_norm_eps", None)
+ if isinstance(eps, bool) or not isinstance(eps, Real):
+ raise TypeError(
+ f"{type(adapter).__name__} FLUX.2 VAE config expected numeric batch_norm_eps, "
+ f"received {type(eps).__name__}: {eps!r}"
+ )
+ if not math.isfinite(float(eps)) or eps < 0:
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.2 VAE batch_norm_eps must be finite and >= 0, "
+ f"received {eps!r}"
+ )
+ mean_values = running_mean.view(1, -1, 1, 1)
+ variance_values = running_var.view(1, -1, 1, 1)
+ if not torch.isfinite(mean_values).all() or not torch.isfinite(variance_values).all():
+ raise ValueError(f"{type(adapter).__name__} FLUX.2 BatchNorm statistics must be finite")
+ if torch.any(variance_values + float(eps) <= 0):
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.2 BatchNorm variance plus epsilon must be positive"
+ )
+ mean = mean_values.to(device=latents.device, dtype=latents.dtype)
+ std = torch.sqrt(variance_values + float(eps)).to(
+ device=latents.device,
+ dtype=latents.dtype,
+ )
+ return (latents - mean) / std
+
+
+def prepare_flux2_condition_latents(
+ adapter: Any,
+ images: List[torch.Tensor],
+ *,
+ batch_size: int,
+ device: torch.device,
+ dtype: torch.dtype,
+) -> Tuple[torch.Tensor, torch.Tensor]:
+ """Compose FLUX.2 condition tokens around the shared VAE transform.
+
+ Args:
+ adapter: FLUX.2 adapter exposing condition packing primitives.
+ images: One or more preprocessed BCHW condition tensors for one sample.
+ batch_size: Number of prompt rows that reuse the condition sequence.
+ device: Target device for condition tensors and identifiers.
+ dtype: VAE input dtype.
+
+ Returns:
+ Packed condition latents and their repeated position identifiers.
+ """
+ if not images:
+ raise ValueError(f"{type(adapter).__name__} requires at least one condition image")
+ if type(batch_size) is not int or batch_size <= 0:
+ raise ValueError(
+ f"{type(adapter).__name__} condition batch_size must be a positive int, "
+ f"received {batch_size!r}"
+ )
+ image_latents = [
+ encode_flux2_vae_image(
+ adapter,
+ image.to(device=device, dtype=dtype),
+ sample_mode="argmax",
+ )
+ for image in images
+ ]
+ image_latent_ids = adapter.pipeline._prepare_image_ids(image_latents)
+ packed_latents = [adapter.pipeline._pack_latents(latent).squeeze(0) for latent in image_latents]
+ packed = torch.cat(packed_latents, dim=0).unsqueeze(0).repeat(batch_size, 1, 1)
+ image_latent_ids = image_latent_ids.repeat(batch_size, 1, 1).to(device)
+ return packed, image_latent_ids
+
+
+__all__ = [
+ "encode_flux1_vae_image",
+ "encode_flux2_output_images",
+ "encode_flux2_vae_image",
+ "prepare_flux2_condition_latents",
+]
diff --git a/src/flow_factory/models/output_state.py b/src/flow_factory/models/output_state.py
new file mode 100644
index 000000000..f760d778e
--- /dev/null
+++ b/src/flow_factory/models/output_state.py
@@ -0,0 +1,698 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Adapter-owned target-media encoding contracts and validation helpers."""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping, Set
+from dataclasses import dataclass, is_dataclass
+from types import MappingProxyType
+from typing import (
+ Any,
+ Optional,
+ Protocol,
+ Sequence,
+ Tuple,
+ Union,
+ cast,
+ runtime_checkable,
+)
+
+import torch
+
+from ..contracts import (
+ NON_MODEL_CONDITION_KEYS,
+ BatchCapability,
+ DecodedMediaLike,
+ MediaType,
+ PipelineIOContract,
+ RateRequirement,
+)
+from ..samples import LatentState
+
+DecodedMediaBatch = Tuple[Tuple[DecodedMediaLike, ...], ...]
+
+
+OUTPUT_STATE_OWNED_KEYS = frozenset(
+ {
+ "clean_state",
+ "decode_context",
+ "forward_context",
+ "geometry_signatures",
+ }
+)
+OUTPUT_FORWARD_CONTEXT_RESERVED_KEYS = NON_MODEL_CONDITION_KEYS | OUTPUT_STATE_OWNED_KEYS
+
+
+@dataclass(frozen=True, slots=True)
+class MediaGeometrySignature:
+ """Describe one encoded output slot with canonical media geometry.
+
+ Args:
+ type: Output modality for this exact sequence slot.
+ height: Encoded image or video height in pixels.
+ width: Encoded image or video width in pixels.
+ frames: Encoded video frame count.
+ fps: Encoded video frame rate when present.
+ samples: Encoded audio sample count.
+ sample_rate: Encoded audio sample rate when present.
+ """
+
+ type: MediaType
+ height: Optional[int] = None
+ width: Optional[int] = None
+ frames: Optional[int] = None
+ fps: Optional[float] = None
+ samples: Optional[int] = None
+ sample_rate: Optional[int] = None
+
+ def __post_init__(self) -> None:
+ """Validate strict modality-specific geometry fields."""
+ if not isinstance(self.type, MediaType):
+ raise TypeError(
+ "expected MediaGeometrySignature.type to be MediaType, "
+ f"received {type(self.type).__name__}: {self.type!r}"
+ )
+ for field_name in ("height", "width", "frames", "samples", "sample_rate"):
+ value = getattr(self, field_name)
+ if value is not None:
+ _require_positive_int(value, f"MediaGeometrySignature.{field_name}")
+ if self.fps is not None:
+ _require_positive_fps(self.fps, "MediaGeometrySignature.fps")
+
+ populated = {
+ name
+ for name in ("height", "width", "frames", "fps", "samples", "sample_rate")
+ if getattr(self, name) is not None
+ }
+ if self.type is MediaType.IMAGE:
+ expected = {"height", "width"}
+ if populated != expected:
+ raise ValueError(
+ "expected image geometry fields ('height', 'width'), received "
+ f"{tuple(sorted(populated))}"
+ )
+ return
+ if self.type is MediaType.VIDEO:
+ required = {"height", "width", "frames"}
+ allowed = required | {"fps"}
+ if not required.issubset(populated) or not populated.issubset(allowed):
+ raise ValueError(
+ "expected video geometry fields ('frames', 'height', 'width') with optional "
+ f"'fps', received {tuple(sorted(populated))}"
+ )
+ return
+ required = {"samples"}
+ allowed = required | {"sample_rate"}
+ if not required.issubset(populated) or not populated.issubset(allowed):
+ raise ValueError(
+ "expected audio geometry field 'samples' with optional 'sample_rate', received "
+ f"{tuple(sorted(populated))}"
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class GeometrySignature:
+ """Describe one sample's exact ordered output-media geometry.
+
+ Args:
+ media: Geometry entries in the pipeline contract's exact output order.
+ """
+
+ media: Tuple[MediaGeometrySignature, ...]
+
+ def __post_init__(self) -> None:
+ """Validate an immutable, non-empty media geometry sequence."""
+ _require_exact_tuple(self.media, MediaGeometrySignature, "GeometrySignature.media")
+ if not self.media:
+ raise ValueError("expected GeometrySignature.media to contain at least one item")
+
+
+@dataclass(frozen=True, slots=True)
+class EncodedOutputState:
+ """Bundle a detached clean latent state with adapter-owned contexts.
+
+ Args:
+ clean_state: Batched clean target latents in adapter component order.
+ forward_context: Output-derived fields that may enter model forward.
+ decode_context: Geometry, rate, and model metadata routed by
+ ``BaseAdapter.decode_output_state`` into the existing decoder. The wrapper filters
+ validation-only fields that ``decode_latents`` does not accept.
+ geometry_signatures: One exact output-geometry signature per batch sample.
+
+ Note:
+ The result freezes its ownership shell and copies both outer context mappings.
+ Tensor leaves and ``LatentState`` are retained without cloning and are revalidated
+ immediately before an offline objective consumes them.
+ """
+
+ clean_state: LatentState
+ forward_context: Mapping[str, Any]
+ decode_context: Mapping[str, Any]
+ geometry_signatures: Tuple[GeometrySignature, ...]
+
+ def __post_init__(self) -> None:
+ """Freeze context mappings and reject malformed result containers."""
+ if not isinstance(self.clean_state, LatentState):
+ raise TypeError(
+ "expected EncodedOutputState.clean_state to be LatentState, "
+ f"received {type(self.clean_state).__name__}"
+ )
+ frozen_forward_context = _freeze_context_mapping(
+ self.forward_context,
+ "EncodedOutputState.forward_context",
+ )
+ rejected = tuple(
+ sorted(set(frozen_forward_context).intersection(OUTPUT_FORWARD_CONTEXT_RESERVED_KEYS))
+ )
+ if rejected:
+ raise ValueError(
+ "EncodedOutputState.forward_context contains non-model or state-owned fields "
+ f"{rejected}"
+ )
+ object.__setattr__(self, "forward_context", frozen_forward_context)
+ object.__setattr__(
+ self,
+ "decode_context",
+ _freeze_context_mapping(
+ self.decode_context,
+ "EncodedOutputState.decode_context",
+ ),
+ )
+ _require_exact_tuple(
+ self.geometry_signatures,
+ GeometrySignature,
+ "EncodedOutputState.geometry_signatures",
+ )
+ if not self.geometry_signatures:
+ raise ValueError(
+ "expected EncodedOutputState.geometry_signatures to contain at least one sample"
+ )
+
+
+@runtime_checkable
+class OutputStateCodec(Protocol):
+ """Define adapter-owned on-the-fly target-media encoding."""
+
+ @property
+ def required_components(self) -> Tuple[str, ...]:
+ """Return adapter component names required while encoding targets."""
+ ...
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Encode one validated media batch into detached clean model state."""
+ ...
+
+
+def validate_output_candidate_batch(
+ media_batch: object,
+ contract: PipelineIOContract,
+) -> DecodedMediaBatch:
+ """Validate decoded target candidates against exact pipeline output semantics.
+
+ Args:
+ media_batch: Tuple of samples, each containing an exact ordered media tuple.
+ contract: Neutral pipeline I/O declaration owned by the adapter.
+
+ Returns:
+ The validated immutable media batch.
+
+ Note:
+ Raw decoded media expose rates but not canonical encoded dimensions. Uniform geometry
+ is therefore enforced against codec-produced signatures by
+ :func:`validate_encoded_output_state`.
+ """
+ _require_contract(contract)
+ if type(media_batch) is not tuple:
+ raise TypeError(
+ "expected output media batch to be tuple, "
+ f"received {type(media_batch).__name__}: {media_batch!r}"
+ )
+ if not media_batch:
+ raise ValueError("expected output media batch to contain at least one sample")
+ if contract.batch_capability is BatchCapability.SINGLE_SAMPLE and len(media_batch) != 1:
+ raise ValueError(
+ "single_sample pipeline expected output media batch size 1, "
+ f"received {len(media_batch)}"
+ )
+
+ expected_items = contract.output_media.items
+ for sample_index, candidate in enumerate(media_batch):
+ if type(candidate) is not tuple:
+ raise TypeError(
+ f"expected output media sample {sample_index} to be tuple, "
+ f"received {type(candidate).__name__}: {candidate!r}"
+ )
+ if len(candidate) != len(expected_items):
+ raise ValueError(
+ f"expected output media sample {sample_index} to contain exact sequence length "
+ f"{len(expected_items)}, received {len(candidate)}"
+ )
+ for media_index, (media, expected) in enumerate(zip(candidate, expected_items)):
+ identifier = f"output media sample {sample_index} item {media_index}"
+ if not isinstance(media, DecodedMediaLike):
+ raise TypeError(
+ f"expected DecodedMediaLike for {identifier}, "
+ f"received {type(media).__name__}"
+ )
+ if type(media.type) is not str:
+ raise TypeError(
+ f"expected {identifier}.type to be str, "
+ f"received {type(media.type).__name__}: {media.type!r}"
+ )
+ if media.type != expected.type.value:
+ raise ValueError(
+ f"expected {identifier}.type {expected.type.value!r}, "
+ f"received {media.type!r}"
+ )
+ if media.payload is None:
+ raise ValueError(f"expected decoded payload for {identifier}, received None")
+ _validate_rate(
+ media.fps,
+ expected.fps,
+ "fps",
+ f"{identifier}.fps",
+ )
+ _validate_rate(
+ media.sample_rate,
+ expected.sample_rate,
+ "sample_rate",
+ f"{identifier}.sample_rate",
+ )
+ return cast(DecodedMediaBatch, media_batch)
+
+
+def validate_encoded_output_state(
+ encoded: object,
+ *,
+ contract: PipelineIOContract,
+ expected_component_order: Tuple[str, ...],
+ expected_batch_size: int,
+ device: Union[torch.device, str],
+) -> EncodedOutputState:
+ """Validate an encoded target result before an offline objective consumes it.
+
+ Args:
+ encoded: Result returned by an adapter's output-state codec.
+ contract: Neutral pipeline I/O declaration owned by the adapter.
+ expected_component_order: Adapter trajectory component order.
+ expected_batch_size: Number of validated target candidates encoded together.
+ device: Device on which model-facing encoded tensors must reside.
+
+ Returns:
+ The validated encoded output state.
+ """
+ if not isinstance(encoded, EncodedOutputState):
+ raise TypeError(
+ "expected encoded output to be EncodedOutputState, "
+ f"received {type(encoded).__name__}"
+ )
+ _require_contract(contract)
+ _validate_component_names(expected_component_order, "expected_component_order")
+ _require_positive_int(expected_batch_size, "expected_batch_size")
+ target_device = torch.device(device)
+
+ clean_state = encoded.clean_state
+ if clean_state.component_names != expected_component_order:
+ raise ValueError(
+ f"expected encoded clean_state component order {expected_component_order}, "
+ f"received {clean_state.component_names}"
+ )
+ for name in expected_component_order:
+ component = clean_state.components.get(name)
+ if not isinstance(component, torch.Tensor):
+ raise TypeError(
+ f"expected clean_state component {name!r} to be torch.Tensor, "
+ f"received {type(component).__name__}"
+ )
+ if component.ndim < 1 or component.shape[0] != expected_batch_size:
+ raise ValueError(
+ f"expected clean_state component {name!r} to use batch size "
+ f"{expected_batch_size}, received shape {tuple(component.shape)}"
+ )
+ if component.dtype not in (torch.float16, torch.bfloat16, torch.float32):
+ raise TypeError(
+ "expected float16, bfloat16, or float32 clean_state component "
+ f"{name!r}, received {component.dtype}"
+ )
+ _validate_tensor_runtime(
+ component,
+ target_device,
+ f"clean_state component {name!r}",
+ )
+
+ if clean_state.active_masks is not None:
+ if tuple(clean_state.active_masks) != expected_component_order:
+ raise ValueError(
+ f"expected clean_state active mask order {expected_component_order}, "
+ f"received {tuple(clean_state.active_masks)}"
+ )
+ for name, mask in clean_state.active_masks.items():
+ if not isinstance(mask, torch.Tensor):
+ raise TypeError(
+ f"expected clean_state active mask {name!r} to be torch.Tensor, "
+ f"received {type(mask).__name__}"
+ )
+ if mask.ndim < 1 or mask.shape[0] != expected_batch_size:
+ raise ValueError(
+ f"expected clean_state active mask {name!r} to use batch size "
+ f"{expected_batch_size}, received shape {tuple(mask.shape)}"
+ )
+ if mask.dtype is not torch.bool:
+ raise TypeError(
+ f"expected clean_state active mask {name!r} dtype torch.bool, "
+ f"received {mask.dtype}"
+ )
+ component_shape = clean_state.components[name].shape
+ if mask.ndim != len(component_shape) or any(
+ mask_dim not in (1, component_dim)
+ for mask_dim, component_dim in zip(mask.shape, component_shape)
+ ):
+ raise ValueError(
+ f"expected clean_state active mask {name!r} broadcastable to component "
+ f"shape {tuple(component_shape)}, received {tuple(mask.shape)}"
+ )
+ _validate_tensor_runtime(mask, target_device, f"clean_state active mask {name!r}")
+
+ if len(encoded.geometry_signatures) != expected_batch_size:
+ raise ValueError(
+ "expected one geometry signature per encoded sample "
+ f"({expected_batch_size}), received {len(encoded.geometry_signatures)}"
+ )
+ for sample_index, signature in enumerate(encoded.geometry_signatures):
+ _validate_geometry_signature(signature, contract, sample_index)
+ has_different_geometry = any(
+ signature != encoded.geometry_signatures[0] for signature in encoded.geometry_signatures[1:]
+ )
+ if contract.batch_capability is BatchCapability.UNIFORM and has_different_geometry:
+ raise ValueError(
+ "uniform pipeline expected identical geometry signatures across the encoded batch, "
+ f"received {encoded.geometry_signatures}"
+ )
+ if (
+ contract.batch_capability is BatchCapability.RAGGED
+ and has_different_geometry
+ and clean_state.active_masks is None
+ ):
+ raise ValueError(
+ "ragged encoded batches with different geometry signatures require active masks "
+ "so padded latent elements cannot contribute to the objective"
+ )
+ if contract.batch_capability is BatchCapability.SINGLE_SAMPLE and expected_batch_size != 1:
+ raise ValueError(
+ "single_sample pipeline expected encoded batch size 1, "
+ f"received {expected_batch_size}"
+ )
+
+ _validate_context_tensor_tree(
+ encoded.forward_context,
+ expected_device=target_device,
+ identifier="EncodedOutputState.forward_context",
+ active_container_ids=set(),
+ )
+ _validate_context_tensor_tree(
+ encoded.decode_context,
+ expected_device=None,
+ identifier="EncodedOutputState.decode_context",
+ active_container_ids=set(),
+ )
+ return encoded
+
+
+def validate_codec_required_components(
+ codec: object,
+ available_components: Sequence[str],
+) -> Tuple[str, ...]:
+ """Validate codec component requirements against one adapter runtime.
+
+ Args:
+ codec: Structural output-state codec instance.
+ available_components: Canonical component names exposed by the adapter runtime.
+
+ Returns:
+ The codec's validated required component tuple.
+ """
+ encode = getattr(codec, "encode_output_state", None)
+ if not callable(encode):
+ raise TypeError(
+ "expected output-state codec with callable encode_output_state, "
+ f"received {type(codec).__name__}"
+ )
+ required_components = getattr(codec, "required_components", None)
+ _validate_component_names(required_components, "codec.required_components", allow_empty=True)
+ if isinstance(available_components, (str, bytes)) or not isinstance(
+ available_components, Sequence
+ ):
+ raise TypeError(
+ "expected available_components to be a sequence of strings, "
+ f"received {type(available_components).__name__}: {available_components!r}"
+ )
+ available = tuple(available_components)
+ _validate_component_names(available, "available_components", allow_empty=True)
+ unknown = tuple(name for name in required_components if name not in available)
+ if unknown:
+ raise ValueError(
+ f"codec requires unavailable adapter components {unknown}; available={available}"
+ )
+ return required_components
+
+
+def _require_contract(contract: object) -> None:
+ if not isinstance(contract, PipelineIOContract):
+ raise TypeError(
+ "expected contract to be PipelineIOContract, " f"received {type(contract).__name__}"
+ )
+
+
+def _require_exact_tuple(value: object, item_type: type, identifier: str) -> None:
+ if type(value) is not tuple:
+ raise TypeError(
+ f"expected {identifier} to be tuple, received {type(value).__name__}: {value!r}"
+ )
+ for index, item in enumerate(value):
+ if type(item) is not item_type:
+ raise TypeError(
+ f"expected {identifier}[{index}] to be {item_type.__name__}, "
+ f"received {type(item).__name__}"
+ )
+
+
+def _require_positive_int(value: object, identifier: str) -> None:
+ if type(value) is not int:
+ raise TypeError(
+ f"expected positive int for {identifier}, received {type(value).__name__}: {value!r}"
+ )
+ if value <= 0:
+ raise ValueError(f"expected positive int for {identifier}, received {value}")
+
+
+def _require_positive_fps(value: object, identifier: str) -> None:
+ if type(value) is not float:
+ raise TypeError(
+ f"expected positive finite float for {identifier}, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ if not math.isfinite(value) or value <= 0:
+ raise ValueError(f"expected positive finite float for {identifier}, received {value!r}")
+
+
+def _freeze_context_mapping(value: object, identifier: str) -> Mapping[str, Any]:
+ if not isinstance(value, Mapping):
+ raise TypeError(
+ f"expected Mapping[str, Any] for {identifier}, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ copied = {}
+ for key, item in value.items():
+ if type(key) is not str:
+ raise TypeError(
+ f"expected string keys for {identifier}, received " f"{type(key).__name__}: {key!r}"
+ )
+ if not key:
+ raise ValueError(f"expected non-empty string keys for {identifier}")
+ copied[key] = item
+ return MappingProxyType(copied)
+
+
+def _validate_component_names(
+ names: object,
+ identifier: str,
+ *,
+ allow_empty: bool = False,
+) -> None:
+ if type(names) is not tuple:
+ raise TypeError(
+ f"expected {identifier} to be tuple, received {type(names).__name__}: {names!r}"
+ )
+ for index, name in enumerate(names):
+ if type(name) is not str:
+ raise TypeError(
+ f"expected {identifier}[{index}] to be str, received "
+ f"{type(name).__name__}: {name!r}"
+ )
+ if not name:
+ raise ValueError(f"expected non-empty component name for {identifier}[{index}]")
+ if not names and not allow_empty:
+ raise ValueError(f"expected {identifier} to contain at least one component")
+ if len(set(names)) != len(names):
+ raise ValueError(f"expected unique component names for {identifier}, received {names}")
+
+
+def _validate_rate(
+ value: object,
+ requirement: RateRequirement,
+ rate_name: str,
+ identifier: str,
+) -> None:
+ if requirement is RateRequirement.NOT_APPLICABLE:
+ if value is not None:
+ raise ValueError(f"expected {identifier}=None for this media type, received {value!r}")
+ return
+ if value is None:
+ if requirement is RateRequirement.REQUIRED:
+ raise ValueError(f"expected required {identifier}, received None")
+ return
+ if rate_name == "fps":
+ _require_positive_fps(value, identifier)
+ else:
+ _require_positive_int(value, identifier)
+
+
+def _validate_geometry_signature(
+ signature: GeometrySignature,
+ contract: PipelineIOContract,
+ sample_index: int,
+) -> None:
+ expected_items = contract.output_media.items
+ if len(signature.media) != len(expected_items):
+ raise ValueError(
+ f"expected geometry signature {sample_index} exact media sequence length "
+ f"{len(expected_items)}, received {len(signature.media)}"
+ )
+ for media_index, (geometry, expected) in enumerate(zip(signature.media, expected_items)):
+ identifier = f"geometry signature {sample_index} item {media_index}"
+ if geometry.type is not expected.type:
+ raise ValueError(
+ f"expected {identifier}.type {expected.type.value!r}, "
+ f"received {geometry.type.value!r}"
+ )
+ _validate_rate(geometry.fps, expected.fps, "fps", f"{identifier}.fps")
+ _validate_rate(
+ geometry.sample_rate,
+ expected.sample_rate,
+ "sample_rate",
+ f"{identifier}.sample_rate",
+ )
+
+
+def _validate_tensor_runtime(
+ tensor: torch.Tensor,
+ device: torch.device,
+ identifier: str,
+) -> None:
+ if tensor.device != device:
+ raise ValueError(f"expected {identifier} on device {device}, received {tensor.device}")
+ if tensor.requires_grad or tensor.grad_fn is not None:
+ raise ValueError(
+ f"expected detached no-grad tensor for {identifier}, received "
+ f"requires_grad={tensor.requires_grad}, grad_fn={tensor.grad_fn}"
+ )
+
+
+def _validate_context_tensor_tree(
+ value: Any,
+ expected_device: Optional[torch.device],
+ identifier: str,
+ active_container_ids: set[int],
+) -> None:
+ if isinstance(value, torch.Tensor):
+ if expected_device is not None and value.device != expected_device:
+ raise ValueError(
+ f"expected {identifier} on device {expected_device}, received {value.device}"
+ )
+ if value.requires_grad or value.grad_fn is not None:
+ raise ValueError(
+ f"expected detached no-grad tensor for {identifier}, received "
+ f"requires_grad={value.requires_grad}, grad_fn={value.grad_fn}"
+ )
+ return
+ if isinstance(value, Mapping):
+ container_id = id(value)
+ if container_id in active_container_ids:
+ raise ValueError(f"expected acyclic context tree for {identifier}")
+ active_container_ids.add(container_id)
+ for key, item in value.items():
+ if type(key) is not str:
+ raise TypeError(
+ f"expected string nested context key for {identifier}, "
+ f"received {type(key).__name__}: {key!r}"
+ )
+ if not key:
+ raise ValueError(f"expected non-empty nested context key for {identifier}")
+ _validate_context_tensor_tree(
+ item,
+ expected_device,
+ f"{identifier}[{key!r}]",
+ active_container_ids,
+ )
+ active_container_ids.remove(container_id)
+ return
+ if isinstance(value, (list, tuple)):
+ container_id = id(value)
+ if container_id in active_container_ids:
+ raise ValueError(f"expected acyclic context tree for {identifier}")
+ active_container_ids.add(container_id)
+ for index, item in enumerate(value):
+ _validate_context_tensor_tree(
+ item,
+ expected_device,
+ f"{identifier}[{index}]",
+ active_container_ids,
+ )
+ active_container_ids.remove(container_id)
+ return
+ if isinstance(value, Set) or is_dataclass(value):
+ raise TypeError(
+ f"expected tensor context tree for {identifier} to use Mapping, list, tuple, "
+ f"or scalar leaves, received unsupported {type(value).__name__}"
+ )
+ if value is not None and not isinstance(
+ value,
+ (str, bytes, bool, int, float, torch.dtype, torch.device),
+ ):
+ raise TypeError(
+ f"expected scalar leaf for {identifier}, received unsupported "
+ f"{type(value).__name__}: {value!r}"
+ )
+
+
+__all__ = [
+ "DecodedMediaBatch",
+ "EncodedOutputState",
+ "GeometrySignature",
+ "MediaGeometrySignature",
+ "OUTPUT_FORWARD_CONTEXT_RESERVED_KEYS",
+ "OUTPUT_STATE_OWNED_KEYS",
+ "OutputStateCodec",
+ "validate_codec_required_components",
+ "validate_encoded_output_state",
+ "validate_output_candidate_batch",
+]
diff --git a/src/flow_factory/models/pipeline_contracts.py b/src/flow_factory/models/pipeline_contracts.py
new file mode 100644
index 000000000..f8fe1cce9
--- /dev/null
+++ b/src/flow_factory/models/pipeline_contracts.py
@@ -0,0 +1,168 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Small constructors for repeated adapter pipeline I/O declarations."""
+
+from __future__ import annotations
+
+from typing import Optional
+
+from ..contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaRule,
+ InputMediaSpec,
+ MediaFormat,
+ MediaType,
+ NegativePromptPolicy,
+ OutputMediaSequence,
+ PipelineIOContract,
+ RateRequirement,
+)
+
+IMAGE_FORMAT = MediaFormat(
+ type=MediaType.IMAGE,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+VIDEO_FORMAT_OPTIONAL_FPS = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.OPTIONAL,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+VIDEO_FORMAT_REQUIRED_FPS = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.REQUIRED,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+AUDIO_FORMAT_REQUIRED_RATE = MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.REQUIRED,
+)
+
+
+def image_output_contract(
+ *,
+ negative_prompt: NegativePromptPolicy,
+ input_image_min_count: Optional[int] = None,
+ input_image_max_count: Optional[int] = None,
+ input_order: InputMediaOrder = InputMediaOrder.INSENSITIVE,
+ input_binding: InputMediaBinding = InputMediaBinding.GROUPED_BY_TYPE,
+ geometry_source: GeometrySource = GeometrySource.CONFIGURED,
+ batch_capability: BatchCapability = BatchCapability.UNIFORM,
+) -> PipelineIOContract:
+ """Build an exact one-image output declaration with optional image inputs.
+
+ ``input_image_min_count=None`` means the pipeline accepts no input image field.
+ A value of zero creates an optional image rule, while a positive value creates
+ a required rule.
+
+ Args:
+ negative_prompt: Whether negative prompts are unsupported, optional, or required.
+ input_image_min_count: Minimum condition-image count, or ``None`` for no image input.
+ input_image_max_count: Maximum condition-image count when an image rule is present.
+ input_order: Ordering semantics for condition images.
+ input_binding: Whether input media is grouped by type or preserves manifest order.
+ geometry_source: Boundary that determines output geometry.
+ batch_capability: Whether the adapter accepts uniform batches or one sample only.
+
+ Returns:
+ Immutable pipeline I/O contract for one image output.
+ """
+ rules = ()
+ if input_image_min_count is not None:
+ rules = (
+ InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=input_image_min_count,
+ max_count=input_image_max_count,
+ ),
+ )
+ return PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=rules,
+ binding=input_binding,
+ order=input_order,
+ ),
+ negative_prompt=negative_prompt,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=geometry_source,
+ batch_capability=batch_capability,
+ )
+
+
+def video_output_contract(
+ *,
+ negative_prompt: NegativePromptPolicy,
+ input_image_min_count: Optional[int] = None,
+ input_image_max_count: Optional[int] = None,
+ output_fps: RateRequirement = RateRequirement.OPTIONAL,
+ geometry_source: GeometrySource = GeometrySource.OUTPUT_MEDIA,
+ batch_capability: BatchCapability = BatchCapability.UNIFORM,
+) -> PipelineIOContract:
+ """Build an exact one-video output declaration.
+
+ Args:
+ negative_prompt: Whether negative prompts are unsupported, optional, or required.
+ input_image_min_count: Minimum condition-image count, or ``None`` for no image input.
+ input_image_max_count: Maximum condition-image count when an image rule is present.
+ output_fps: Whether target video frame rate metadata is required.
+ geometry_source: Boundary that determines output geometry.
+ batch_capability: Whether the adapter accepts uniform batches or one sample only.
+
+ Returns:
+ Immutable pipeline I/O contract for one video output.
+ """
+ rules = ()
+ if input_image_min_count is not None:
+ rules = (
+ InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=input_image_min_count,
+ max_count=input_image_max_count,
+ ),
+ )
+ video_format = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=output_fps,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+ return PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=rules,
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=(
+ InputMediaOrder.INSENSITIVE
+ if input_image_min_count is None or input_image_max_count == 1
+ else InputMediaOrder.WITHIN_TYPE
+ ),
+ ),
+ negative_prompt=negative_prompt,
+ output_media=OutputMediaSequence(items=(video_format,)),
+ geometry_source=geometry_source,
+ batch_capability=batch_capability,
+ )
+
+
+__all__ = [
+ "AUDIO_FORMAT_REQUIRED_RATE",
+ "IMAGE_FORMAT",
+ "VIDEO_FORMAT_OPTIONAL_FPS",
+ "VIDEO_FORMAT_REQUIRED_FPS",
+ "image_output_contract",
+ "video_output_contract",
+]
diff --git a/src/flow_factory/models/qwen_image/_output.py b/src/flow_factory/models/qwen_image/_output.py
new file mode 100644
index 000000000..a9b585f4e
--- /dev/null
+++ b/src/flow_factory/models/qwen_image/_output.py
@@ -0,0 +1,189 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Role-neutral VAE encoding shared by Qwen-Image pipeline variants."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import Any, Optional
+
+import torch
+
+from ..configured_image_output import (
+ EncodedImageTensor,
+ VAELatentSampleMode,
+ retrieve_vae_latents,
+)
+
+
+def encode_qwen_vae_image(
+ adapter: Any,
+ video_values: torch.Tensor,
+ *,
+ sample_mode: VAELatentSampleMode,
+ generator: Optional[torch.Generator] = None,
+) -> torch.Tensor:
+ """Apply explicit posterior selection shared by Qwen image roles.
+
+ Args:
+ adapter: Qwen-Image adapter exposing the canonical VAE.
+ video_values: Preprocessed BCFHW image-as-video tensor.
+ sample_mode: Explicit posterior selection for the calling pipeline role.
+ generator: Generator forwarded unchanged for posterior sampling.
+
+ Returns:
+ Channel-normalized five-dimensional clean latents.
+ """
+ latents = retrieve_vae_latents(
+ adapter.vae.encode(video_values),
+ sample_mode=sample_mode,
+ generator=generator,
+ source=f"{type(adapter).__name__} VAE encode",
+ )
+ latent_channels = latents.shape[1]
+ means = _channel_statistics(
+ adapter.vae.config.latents_mean,
+ latent_channels,
+ latents,
+ "latents_mean",
+ )
+ stds = _channel_statistics(
+ adapter.vae.config.latents_std,
+ latent_channels,
+ latents,
+ "latents_std",
+ )
+ if torch.any(stds <= 0):
+ raise ValueError(f"{type(adapter).__name__} VAE latents_std must be positive")
+ return (latents - means) / stds
+
+
+def encode_qwen_output_images(
+ adapter: Any,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ *,
+ condition_sizes_key: Optional[str] = None,
+) -> EncodedImageTensor:
+ """Apply official 5D Qwen VAE normalization, packing, and shape metadata.
+
+ Args:
+ adapter: Qwen-Image adapter exposing VAE and packing primitives.
+ pixel_values: Preprocessed BCHW target images.
+ condition: Cached model condition for the same batch.
+ generator: Generator forwarded unchanged to target posterior sampling.
+ condition_sizes_key: Optional condition field containing per-image VAE geometry.
+
+ Returns:
+ Packed clean target latents and target-first image-shape metadata.
+ """
+ video_values = pixel_values.unsqueeze(2)
+ latents = encode_qwen_vae_image(
+ adapter,
+ video_values,
+ sample_mode="sample",
+ generator=generator,
+ )
+ latent_channels = latents.shape[1]
+
+ batch_size = latents.shape[0]
+ latent_height, latent_width = latents.shape[-2:]
+ packed = adapter.pipeline._pack_latents(
+ latents,
+ batch_size,
+ latent_channels,
+ latent_height,
+ latent_width,
+ )
+ target_shape = (1, latent_height // 2, latent_width // 2)
+ img_shapes = [[target_shape] for _ in range(batch_size)]
+ if condition_sizes_key is not None:
+ condition_sizes = condition.get(condition_sizes_key)
+ parsed_sizes = _parse_condition_sizes(
+ condition_sizes,
+ batch_size=batch_size,
+ source=f"{type(adapter).__name__} condition[{condition_sizes_key!r}]",
+ )
+ scale = adapter.pipeline.vae_scale_factor * 2
+ for sample_index, sizes in enumerate(parsed_sizes):
+ for width, height in sizes:
+ if width % scale or height % scale:
+ raise ValueError(
+ f"{type(adapter).__name__} condition VAE geometry {(height, width)} "
+ f"must be divisible by {scale}"
+ )
+ img_shapes[sample_index].append((1, height // scale, width // scale))
+
+ return EncodedImageTensor(
+ latents=packed,
+ forward_context={"img_shapes": img_shapes},
+ decode_context={},
+ )
+
+
+def _channel_statistics(
+ values: Any,
+ channels: int,
+ reference: torch.Tensor,
+ name: str,
+) -> torch.Tensor:
+ """Materialize one finite Qwen latent statistic per channel."""
+ statistics = torch.as_tensor(values, device=reference.device, dtype=reference.dtype)
+ if statistics.ndim != 1 or statistics.numel() != channels:
+ raise ValueError(
+ f"Qwen VAE {name} expected {channels} values, received shape "
+ f"{tuple(statistics.shape)}"
+ )
+ if not torch.isfinite(statistics).all():
+ raise ValueError(f"Qwen VAE {name} must contain only finite values")
+ return statistics.view(1, channels, 1, 1, 1)
+
+
+def _parse_condition_sizes(
+ value: Any,
+ *,
+ batch_size: int,
+ source: str,
+) -> list[list[tuple[int, int]]]:
+ """Validate collated per-sample ``(width, height)`` condition geometry."""
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise TypeError(f"{source} must be a per-sample sequence, received {type(value).__name__}")
+ if len(value) != batch_size:
+ raise ValueError(
+ f"{source} expected batch size {batch_size}, received sequence length {len(value)}"
+ )
+ result: list[list[tuple[int, int]]] = []
+ for sample_index, sizes in enumerate(value):
+ if not isinstance(sizes, Sequence) or isinstance(sizes, (str, bytes)):
+ raise TypeError(f"{source}[{sample_index}] must be a sequence of (width, height) pairs")
+ parsed: list[tuple[int, int]] = []
+ for size_index, size in enumerate(sizes):
+ if not isinstance(size, Sequence) or isinstance(size, (str, bytes)) or len(size) != 2:
+ raise TypeError(
+ f"{source}[{sample_index}][{size_index}] must be a (width, height) pair"
+ )
+ width, height = size
+ if type(width) is not int or type(height) is not int or width <= 0 or height <= 0:
+ raise ValueError(
+ f"{source}[{sample_index}][{size_index}] expected positive integer "
+ f"geometry, received {tuple(size)!r}"
+ )
+ parsed.append((width, height))
+ result.append(parsed)
+ return result
+
+
+__all__ = ["encode_qwen_output_images", "encode_qwen_vae_image"]
diff --git a/tests/models/test_output_codec_numerics.py b/tests/models/test_output_codec_numerics.py
new file mode 100644
index 000000000..fd586c8ec
--- /dev/null
+++ b/tests/models/test_output_codec_numerics.py
@@ -0,0 +1,234 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for role-neutral VAE encoding primitives used by conditions and outputs."""
+
+from types import SimpleNamespace
+from typing import Any, Optional
+
+import pytest
+import torch
+
+from flow_factory.models.configured_image_output import (
+ encode_shift_scale_vae_image,
+ retrieve_vae_latents,
+)
+from flow_factory.models.flux._output import (
+ encode_flux1_vae_image,
+ encode_flux2_output_images,
+ encode_flux2_vae_image,
+)
+from flow_factory.models.qwen_image._output import encode_qwen_vae_image
+
+
+class _Posterior:
+ def __init__(self, value: torch.Tensor) -> None:
+ self.value = value
+ self.mode_calls = 0
+ self.sample_generators: list[Optional[torch.Generator]] = []
+
+ def mode(self) -> torch.Tensor:
+ self.mode_calls += 1
+ return self.value
+
+ def sample(self, generator: Optional[torch.Generator] = None) -> torch.Tensor:
+ self.sample_generators.append(generator)
+ return self.value + 7.0
+
+
+class _ShiftScaleVAE:
+ def __init__(self) -> None:
+ self.config = SimpleNamespace(shift_factor=1.25, scaling_factor=2.5)
+ self.posteriors: list[_Posterior] = []
+
+ def encode(self, values: torch.Tensor) -> Any:
+ posterior = _Posterior(values + 3.0)
+ self.posteriors.append(posterior)
+ return SimpleNamespace(latent_dist=posterior)
+
+
+def test_retrieve_vae_latents_selects_argmax_or_sample_and_forwards_generator() -> None:
+ """Posterior selection stays explicit and sampling receives the caller's generator."""
+ posterior = _Posterior(torch.ones(1, 2))
+ encoder_output = SimpleNamespace(latent_dist=posterior)
+ generator = torch.Generator().manual_seed(23)
+
+ argmax = retrieve_vae_latents(
+ encoder_output,
+ sample_mode="argmax",
+ source="condition",
+ )
+ sampled = retrieve_vae_latents(
+ encoder_output,
+ sample_mode="sample",
+ generator=generator,
+ source="target",
+ )
+
+ assert torch.equal(argmax, posterior.value)
+ assert torch.equal(sampled, posterior.value + 7.0)
+ assert posterior.mode_calls == 1
+ assert posterior.sample_generators == [generator]
+
+
+@pytest.mark.parametrize(
+ ("sample_mode", "error_type", "message"),
+ [
+ (None, TypeError, "sample_mode to be str"),
+ ("mode", ValueError, "sample_mode in"),
+ ],
+)
+def test_retrieve_vae_latents_requires_an_explicit_supported_selection(
+ sample_mode: object,
+ error_type: type[Exception],
+ message: str,
+) -> None:
+ """Callers cannot inherit an implicit global posterior policy."""
+ with pytest.raises(error_type, match=message):
+ retrieve_vae_latents(
+ SimpleNamespace(latent_dist=_Posterior(torch.ones(1))),
+ sample_mode=sample_mode, # type: ignore[arg-type]
+ source="test",
+ )
+
+
+def test_shift_scale_primitive_shares_normalization_but_not_posterior_policy() -> None:
+ """Condition and output roles share math while selecting their official posterior path."""
+ pixels = torch.randn(2, 3, 8, 8)
+ adapter = SimpleNamespace(vae=_ShiftScaleVAE())
+ generator = torch.Generator().manual_seed(29)
+
+ condition_latents = encode_shift_scale_vae_image(
+ adapter,
+ pixels,
+ sample_mode="argmax",
+ source="condition",
+ )
+ output_latents = encode_shift_scale_vae_image(
+ adapter,
+ pixels,
+ sample_mode="sample",
+ generator=generator,
+ source="output",
+ )
+
+ assert torch.equal(condition_latents, (pixels + 3.0 - 1.25) * 2.5)
+ assert torch.equal(
+ output_latents,
+ (adapter.vae.posteriors[1].value + 7.0 - 1.25) * 2.5,
+ )
+ assert adapter.vae.posteriors[0].mode_calls == 1
+ assert adapter.vae.posteriors[1].sample_generators == [generator]
+
+
+def test_flux1_wrapper_delegates_to_the_role_neutral_shift_scale_primitive() -> None:
+ """FLUX.1 condition and target orchestration cannot drift numerically."""
+ pixels = torch.randn(1, 3, 8, 8)
+ adapter = SimpleNamespace(vae=_ShiftScaleVAE())
+
+ assert torch.equal(
+ encode_flux1_vae_image(adapter, pixels, sample_mode="argmax"),
+ (pixels + 3.0 - 1.25) * 2.5,
+ )
+
+
+def test_flux2_primitive_applies_posterior_argmax_patchify_and_batch_norm() -> None:
+ """FLUX.2 keeps the official deterministic transform in one shared helper."""
+ pixels = torch.randn(2, 2, 4, 4)
+ posterior = _Posterior(pixels + 1.0)
+ vae = SimpleNamespace(
+ encode=lambda values: SimpleNamespace(latent_dist=posterior),
+ bn=SimpleNamespace(
+ running_mean=torch.tensor([0.25, -0.5]),
+ running_var=torch.tensor([0.75, 1.25]),
+ ),
+ config=SimpleNamespace(batch_norm_eps=1e-5),
+ )
+ adapter = SimpleNamespace(
+ vae=vae,
+ pipeline=SimpleNamespace(_patchify_latents=lambda values: values),
+ )
+
+ actual = encode_flux2_vae_image(adapter, pixels, sample_mode="argmax")
+
+ mean = vae.bn.running_mean.view(1, -1, 1, 1)
+ std = torch.sqrt(vae.bn.running_var.view(1, -1, 1, 1) + 1e-5)
+ assert torch.allclose(actual, (pixels + 1.0 - mean) / std)
+ assert posterior.mode_calls == 1
+
+
+def test_flux2_output_orchestration_samples_with_the_passed_generator() -> None:
+ """The target-only wrapper fixes selection to sample without replacing its RNG."""
+ pixels = torch.randn(1, 2, 4, 4)
+ posterior = _Posterior(pixels)
+ vae = SimpleNamespace(
+ encode=lambda values: SimpleNamespace(latent_dist=posterior),
+ bn=SimpleNamespace(running_mean=torch.zeros(2), running_var=torch.ones(2)),
+ config=SimpleNamespace(batch_norm_eps=0.0),
+ )
+ pipeline = SimpleNamespace(
+ _patchify_latents=lambda values: values,
+ _prepare_latent_ids=lambda values: torch.zeros(4, 3),
+ _pack_latents=lambda values: values.flatten(2).transpose(1, 2),
+ )
+ adapter = SimpleNamespace(vae=vae, pipeline=pipeline, device=torch.device("cpu"))
+ generator = torch.Generator().manual_seed(31)
+
+ encoded = encode_flux2_output_images(adapter, pixels, generator)
+
+ assert encoded.latents.shape == (1, 16, 2)
+ assert posterior.sample_generators == [generator]
+ assert posterior.mode_calls == 0
+
+
+def test_qwen_primitive_normalizes_five_dimensional_latents_per_channel() -> None:
+ """Qwen condition and target roles share one BCFHW channel transform."""
+ pixels = torch.randn(2, 2, 1, 4, 4)
+ posterior = _Posterior(pixels + 2.0)
+ vae = SimpleNamespace(
+ encode=lambda values: SimpleNamespace(latent_dist=posterior),
+ config=SimpleNamespace(latents_mean=[1.0, -2.0], latents_std=[2.0, 4.0]),
+ )
+ adapter = SimpleNamespace(vae=vae)
+
+ actual = encode_qwen_vae_image(adapter, pixels, sample_mode="argmax")
+
+ mean = torch.tensor([1.0, -2.0]).view(1, 2, 1, 1, 1)
+ std = torch.tensor([2.0, 4.0]).view(1, 2, 1, 1, 1)
+ assert torch.equal(actual, (pixels + 2.0 - mean) / std)
+ assert posterior.mode_calls == 1
+
+
+@pytest.mark.parametrize(
+ ("shift", "scale", "message"),
+ [
+ (None, 1.0, "shift_factor"),
+ (0.0, 0.0, "scaling_factor > 0"),
+ ],
+)
+def test_shift_scale_primitive_rejects_ambiguous_vae_normalization(
+ shift: object,
+ scale: object,
+ message: str,
+) -> None:
+ """A missing or invalid VAE normalization fails at the shared boundary."""
+ vae = _ShiftScaleVAE()
+ vae.config = SimpleNamespace(shift_factor=shift, scaling_factor=scale)
+
+ with pytest.raises((TypeError, ValueError), match=message):
+ encode_shift_scale_vae_image(
+ SimpleNamespace(vae=vae),
+ torch.zeros(1, 1, 2, 2),
+ sample_mode="argmax",
+ )
diff --git a/tests/models/test_output_state.py b/tests/models/test_output_state.py
new file mode 100644
index 000000000..787c82096
--- /dev/null
+++ b/tests/models/test_output_state.py
@@ -0,0 +1,585 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for adapter-owned target output-state contracts."""
+
+from dataclasses import FrozenInstanceError, dataclass
+from typing import Any, Mapping, Optional, Tuple
+
+import pytest
+import torch
+
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaSpec,
+ MediaFormat,
+ MediaType,
+ NegativePromptPolicy,
+ OutputMediaSequence,
+ PipelineIOContract,
+ RateRequirement,
+)
+from flow_factory.models.output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+ OutputStateCodec,
+ validate_codec_required_components,
+ validate_encoded_output_state,
+ validate_output_candidate_batch,
+)
+from flow_factory.samples import LatentState
+
+IMAGE_FORMAT = MediaFormat(
+ type=MediaType.IMAGE,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+VIDEO_FORMAT = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.REQUIRED,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+AUDIO_FORMAT = MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.REQUIRED,
+)
+
+
+@dataclass
+class _DecodedMedia:
+ type: str
+ payload: object
+ fps: Optional[float] = None
+ sample_rate: Optional[int] = None
+
+
+def _contract(
+ *output: MediaFormat,
+ batch_capability: BatchCapability = BatchCapability.UNIFORM,
+) -> PipelineIOContract:
+ return PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ output_media=OutputMediaSequence(items=tuple(output)),
+ geometry_source=GeometrySource.OUTPUT_MEDIA,
+ batch_capability=batch_capability,
+ )
+
+
+def _image_signature(height: int = 32, width: int = 48) -> GeometrySignature:
+ return GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=height,
+ width=width,
+ ),
+ )
+ )
+
+
+def _encoded_image_batch(
+ batch_size: int = 2,
+ *,
+ component: Optional[torch.Tensor] = None,
+ signatures: Optional[Tuple[GeometrySignature, ...]] = None,
+ forward_context: Optional[Mapping[str, Any]] = None,
+ decode_context: Optional[Mapping[str, Any]] = None,
+) -> EncodedOutputState:
+ if component is None:
+ component = torch.zeros(batch_size, 4, 8, 12)
+ if signatures is None:
+ signatures = tuple(_image_signature() for _ in range(batch_size))
+ return EncodedOutputState(
+ clean_state=LatentState({"latent": component}),
+ forward_context={} if forward_context is None else forward_context,
+ decode_context={} if decode_context is None else decode_context,
+ geometry_signatures=signatures,
+ )
+
+
+def test_validate_image_candidate_batch_preserves_structural_media_objects() -> None:
+ media_batch = (
+ (_DecodedMedia(type="image", payload=object()),),
+ (_DecodedMedia(type="image", payload=object()),),
+ )
+
+ validated = validate_output_candidate_batch(media_batch, _contract(IMAGE_FORMAT))
+
+ assert validated is media_batch
+
+
+def test_validate_multimodal_candidate_enforces_exact_sequence_and_required_rates() -> None:
+ media_batch = (
+ (
+ _DecodedMedia(type="video", payload=object(), fps=24.0),
+ _DecodedMedia(type="audio", payload=object(), sample_rate=48_000),
+ ),
+ )
+
+ assert (
+ validate_output_candidate_batch(
+ media_batch,
+ _contract(VIDEO_FORMAT, AUDIO_FORMAT),
+ )
+ is media_batch
+ )
+
+ with pytest.raises(ValueError, match="exact sequence length 2"):
+ validate_output_candidate_batch(
+ (media_batch[0][:1],), _contract(VIDEO_FORMAT, AUDIO_FORMAT)
+ )
+ with pytest.raises(ValueError, match=r"item 0\.type 'video'"):
+ validate_output_candidate_batch(
+ (
+ (
+ _DecodedMedia(type="audio", payload=object(), sample_rate=48_000),
+ media_batch[0][1],
+ ),
+ ),
+ _contract(VIDEO_FORMAT, AUDIO_FORMAT),
+ )
+ with pytest.raises(ValueError, match="required.*fps"):
+ validate_output_candidate_batch(
+ (
+ (
+ _DecodedMedia(type="video", payload=object()),
+ media_batch[0][1],
+ ),
+ ),
+ _contract(VIDEO_FORMAT, AUDIO_FORMAT),
+ )
+ with pytest.raises(ValueError, match="required.*sample_rate"):
+ validate_output_candidate_batch(
+ (
+ (
+ media_batch[0][0],
+ _DecodedMedia(type="audio", payload=object()),
+ ),
+ ),
+ _contract(VIDEO_FORMAT, AUDIO_FORMAT),
+ )
+
+
+@pytest.mark.parametrize(
+ "media_batch,match",
+ [
+ ([], "output media batch to be tuple"),
+ (([_DecodedMedia(type="image", payload=object())],), "sample 0 to be tuple"),
+ (((_DecodedMedia(type="image", payload=None),),), "decoded payload"),
+ (
+ ((_DecodedMedia(type="image", payload=object(), fps=1.0),),),
+ "fps=None",
+ ),
+ ],
+)
+def test_candidate_validation_rejects_mutable_or_incoherent_media(
+ media_batch: object,
+ match: str,
+) -> None:
+ error_type = TypeError if "tuple" in match else ValueError
+ with pytest.raises(error_type, match=match):
+ validate_output_candidate_batch(media_batch, _contract(IMAGE_FORMAT))
+
+
+def test_single_sample_contract_rejects_larger_candidate_batch() -> None:
+ media_batch = tuple((_DecodedMedia(type="image", payload=object()),) for _ in range(2))
+ with pytest.raises(ValueError, match="batch size 1"):
+ validate_output_candidate_batch(
+ media_batch,
+ _contract(IMAGE_FORMAT, batch_capability=BatchCapability.SINGLE_SAMPLE),
+ )
+
+
+def test_media_geometry_signature_is_strict_coherent_and_hashable() -> None:
+ image = MediaGeometrySignature(type=MediaType.IMAGE, height=32, width=48)
+ video = MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=32,
+ width=48,
+ frames=9,
+ fps=24.0,
+ )
+ audio = MediaGeometrySignature(
+ type=MediaType.AUDIO,
+ samples=18_000,
+ sample_rate=48_000,
+ )
+ signature = GeometrySignature(media=(video, audio))
+
+ assert image.height == 32
+ assert hash(signature)
+ with pytest.raises(TypeError, match="type to be MediaType"):
+ MediaGeometrySignature(type="image", height=32, width=48) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="expected image geometry fields"):
+ MediaGeometrySignature(type=MediaType.IMAGE, height=32)
+ with pytest.raises(ValueError, match="expected video geometry fields"):
+ MediaGeometrySignature(type=MediaType.VIDEO, height=32, width=48)
+ with pytest.raises(TypeError, match="positive finite float"):
+ MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=32,
+ width=48,
+ frames=9,
+ fps=24, # type: ignore[arg-type]
+ )
+
+
+def test_encoded_output_state_freezes_outer_contexts_without_copying_tensor_leaves() -> None:
+ ids = torch.zeros(2, 4, 3)
+ forward_context = {"img_ids": ids}
+ encoded = _encoded_image_batch(
+ forward_context=forward_context,
+ decode_context={"height": 32, "width": 48},
+ )
+ forward_context["late_mutation"] = True
+
+ assert encoded.forward_context["img_ids"] is ids
+ assert "late_mutation" not in encoded.forward_context
+ with pytest.raises(TypeError):
+ encoded.forward_context["other"] = 1 # type: ignore[index]
+ with pytest.raises(FrozenInstanceError):
+ encoded.clean_state = LatentState({"latent": torch.zeros(2, 1)}) # type: ignore[misc]
+
+
+@pytest.mark.parametrize(
+ "key",
+ [
+ "state",
+ "latents",
+ "return_fields",
+ "record_ids",
+ "target_media",
+ "clean_state",
+ ],
+)
+def test_encoded_output_state_rejects_reserved_forward_context_keys(key: str) -> None:
+ with pytest.raises(ValueError, match=rf"non-model or state-owned.*{key}"):
+ _encoded_image_batch(forward_context={key: object()})
+
+
+def test_validate_encoded_output_state_accepts_detached_uniform_image_state() -> None:
+ encoded = _encoded_image_batch(
+ forward_context={"img_ids": torch.zeros(2, 4, 3)},
+ decode_context={"height": 32, "width": 48},
+ )
+
+ validated = validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+ assert validated is encoded
+
+
+def test_validate_encoded_output_state_checks_component_order_batch_and_dtype() -> None:
+ encoded = _encoded_image_batch()
+ with pytest.raises(ValueError, match="component order.*video"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("video",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+ with pytest.raises(ValueError, match="batch size 3"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=3,
+ device="cpu",
+ )
+ integer_state = _encoded_image_batch(component=torch.zeros(2, 4, dtype=torch.int64))
+ with pytest.raises(TypeError, match="expected float16, bfloat16, or float32"):
+ validate_encoded_output_state(
+ integer_state,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+
+def test_validate_encoded_output_state_revalidates_mutable_latent_active_masks() -> None:
+ clean_state = LatentState(
+ {"latent": torch.zeros(2, 4)},
+ active_masks={"latent": torch.ones(2, 1, dtype=torch.bool)},
+ )
+ clean_state.active_masks["latent"] = torch.ones(2, 1)
+ encoded = EncodedOutputState(
+ clean_state=clean_state,
+ forward_context={},
+ decode_context={},
+ geometry_signatures=(_image_signature(), _image_signature()),
+ )
+
+ with pytest.raises(TypeError, match="active mask.*dtype torch.bool"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+
+def test_validate_encoded_output_state_checks_device_and_no_grad_tensors() -> None:
+ requires_grad = _encoded_image_batch(component=torch.zeros(2, 4, requires_grad=True))
+ with pytest.raises(ValueError, match="detached no-grad.*latent"):
+ validate_encoded_output_state(
+ requires_grad,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+ wrong_device = _encoded_image_batch(component=torch.empty(2, 4, device="meta"))
+ with pytest.raises(ValueError, match="on device cpu.*meta"):
+ validate_encoded_output_state(
+ wrong_device,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+ context_grad = _encoded_image_batch(
+ forward_context={"img_ids": torch.zeros(2, 3, requires_grad=True)}
+ )
+ with pytest.raises(ValueError, match="detached no-grad.*img_ids"):
+ validate_encoded_output_state(
+ context_grad,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+ decode_context_device = _encoded_image_batch(
+ decode_context={"sizes": torch.empty(2, 2, device="meta")}
+ )
+ assert (
+ validate_encoded_output_state(
+ decode_context_device,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+ is decode_context_device
+ )
+
+
+def test_validate_encoded_output_state_rejects_unsupported_context_containers() -> None:
+ encoded = _encoded_image_batch(decode_context={"sizes": {32, 48}})
+
+ with pytest.raises(TypeError, match="unsupported set"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+
+def test_validate_encoded_output_state_rejects_cyclic_context_trees() -> None:
+ nested: dict[str, Any] = {}
+ nested["cycle"] = nested
+ encoded = _encoded_image_batch(decode_context={"nested": nested})
+
+ with pytest.raises(ValueError, match="acyclic context tree"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+
+def test_validate_encoded_output_state_checks_signatures_and_uniform_geometry() -> None:
+ with pytest.raises(ValueError, match="one geometry signature per encoded sample"):
+ validate_encoded_output_state(
+ _encoded_image_batch(signatures=(_image_signature(),)),
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+ ragged_signatures = (_image_signature(32, 48), _image_signature(48, 32))
+ encoded = _encoded_image_batch(signatures=ragged_signatures)
+ with pytest.raises(ValueError, match="identical geometry signatures"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+ with pytest.raises(ValueError, match="require active masks"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT, batch_capability=BatchCapability.RAGGED),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+ masked = EncodedOutputState(
+ clean_state=LatentState(
+ {"latent": encoded.clean_state.components["latent"]},
+ active_masks={"latent": torch.ones(2, 1, 1, 1, dtype=torch.bool)},
+ ),
+ forward_context=encoded.forward_context,
+ decode_context=encoded.decode_context,
+ geometry_signatures=encoded.geometry_signatures,
+ )
+ assert (
+ validate_encoded_output_state(
+ masked,
+ contract=_contract(IMAGE_FORMAT, batch_capability=BatchCapability.RAGGED),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+ is masked
+ )
+
+
+def test_validate_encoded_output_state_rejects_float64_components() -> None:
+ encoded = _encoded_image_batch(component=torch.zeros(2, 4, dtype=torch.float64))
+
+ with pytest.raises(TypeError, match="expected float16, bfloat16, or float32"):
+ validate_encoded_output_state(
+ encoded,
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+
+def test_geometry_signature_must_match_exact_output_types_and_rate_policy() -> None:
+ audio_geometry = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.AUDIO,
+ samples=18_000,
+ sample_rate=48_000,
+ ),
+ )
+ )
+ with pytest.raises(ValueError, match=r"item 0\.type 'image'"):
+ validate_encoded_output_state(
+ _encoded_image_batch(signatures=(audio_geometry, audio_geometry)),
+ contract=_contract(IMAGE_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=2,
+ device="cpu",
+ )
+
+ video_without_fps = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=32,
+ width=48,
+ frames=9,
+ ),
+ )
+ )
+ video_state = EncodedOutputState(
+ clean_state=LatentState({"latent": torch.zeros(1, 4, 9, 4, 6)}),
+ forward_context={},
+ decode_context={},
+ geometry_signatures=(video_without_fps,),
+ )
+ with pytest.raises(ValueError, match="required.*fps"):
+ validate_encoded_output_state(
+ video_state,
+ contract=_contract(VIDEO_FORMAT),
+ expected_component_order=("latent",),
+ expected_batch_size=1,
+ device="cpu",
+ )
+
+
+class _ImageCodec:
+ required_components = ("vae", "image_processor")
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ del media_batch, condition, generator
+ return _encoded_image_batch(batch_size=1)
+
+
+def test_output_state_codec_is_structural_and_required_components_are_validated() -> None:
+ codec = _ImageCodec()
+
+ assert isinstance(codec, OutputStateCodec)
+ assert validate_codec_required_components(
+ codec,
+ ("transformer", "vae", "image_processor"),
+ ) == ("vae", "image_processor")
+
+
+@pytest.mark.parametrize(
+ "required,available,error_type,match",
+ [
+ (["vae"], ("vae",), TypeError, "required_components to be tuple"),
+ (("vae", "vae"), ("vae",), ValueError, "unique component names"),
+ (("decoder",), ("vae",), ValueError, "unavailable adapter components"),
+ (("",), ("vae",), ValueError, "non-empty component name"),
+ ],
+)
+def test_codec_required_component_validation_fails_fast(
+ required: object,
+ available: Tuple[str, ...],
+ error_type: type[Exception],
+ match: str,
+) -> None:
+ codec = _ImageCodec()
+ codec.required_components = required # type: ignore[assignment]
+
+ with pytest.raises(error_type, match=match):
+ validate_codec_required_components(codec, available)
+
+
+def test_codec_required_component_validation_rejects_missing_encode_method() -> None:
+ class _NotACodec:
+ required_components = ("vae",)
+
+ with pytest.raises(TypeError, match="callable encode_output_state"):
+ validate_codec_required_components(_NotACodec(), ("vae",))
diff --git a/tests/models/test_output_state_adapter_lifecycle.py b/tests/models/test_output_state_adapter_lifecycle.py
new file mode 100644
index 000000000..1b4fad7cb
--- /dev/null
+++ b/tests/models/test_output_state_adapter_lifecycle.py
@@ -0,0 +1,471 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Lightweight tests for the BaseAdapter output-state codec lifecycle seam."""
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Any, Mapping, Optional, Tuple
+
+import pytest
+import torch
+
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaSpec,
+ MediaFormat,
+ MediaType,
+ NegativePromptPolicy,
+ OutputMediaSequence,
+ PipelineIOContract,
+ RateRequirement,
+)
+from flow_factory.models.abc import BaseAdapter
+from flow_factory.models.output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+from flow_factory.samples import LatentState
+
+IMAGE_CONTRACT = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(
+ items=(
+ MediaFormat(
+ type=MediaType.IMAGE,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ ),
+ )
+ ),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
+)
+
+
+@dataclass(frozen=True)
+class _DecodedMedia:
+ type: str
+ payload: Any
+ fps: Optional[float] = None
+ sample_rate: Optional[int] = None
+
+
+class _Scheduler:
+ def step(self) -> None:
+ """Provide the scheduler-like surface required by SchedulerGroup."""
+
+
+def _config(latent_storage_dtype: Optional[str] = None) -> SimpleNamespace:
+ return SimpleNamespace(
+ model_args=SimpleNamespace(
+ resume_path=None,
+ resume_type=None,
+ finetune_type="full",
+ target_components=["transformer_alias"],
+ ),
+ training_args=SimpleNamespace(
+ enable_gradient_checkpointing=False,
+ latent_storage_dtype=latent_storage_dtype,
+ ),
+ eval_args=SimpleNamespace(),
+ )
+
+
+def _image_batch(batch_size: int = 2) -> DecodedMediaBatch:
+ return tuple(
+ (_DecodedMedia(type="image", payload=torch.zeros(3, 8, 8)),) for _ in range(batch_size)
+ )
+
+
+def _encoded_image_batch(
+ batch_size: int,
+ *,
+ component_name: str = "latent",
+ tensor: Optional[torch.Tensor] = None,
+) -> EncodedOutputState:
+ if tensor is None:
+ tensor = torch.ones(batch_size, 2, dtype=torch.float32)
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=8,
+ width=8,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=LatentState({component_name: tensor}),
+ forward_context={},
+ decode_context={"height": 8, "width": 8},
+ geometry_signatures=tuple(signature for _ in range(batch_size)),
+ )
+
+
+class _Codec:
+ def __init__(
+ self,
+ required_components: Tuple[str, ...] = ("vae",),
+ result: Optional[EncodedOutputState] = None,
+ ) -> None:
+ self.required_components = required_components
+ self.result = result
+ self.calls = 0
+ self.grad_enabled: Optional[bool] = None
+ self.received: Optional[Tuple[Any, ...]] = None
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ self.calls += 1
+ self.grad_enabled = torch.is_grad_enabled()
+ self.received = (media_batch, condition, generator)
+ return self.result or _encoded_image_batch(len(media_batch))
+
+
+class _LifecycleAdapter(BaseAdapter):
+ pipeline_io_contract = IMAGE_CONTRACT
+
+ def __init__(
+ self,
+ codec: Optional[_Codec],
+ *,
+ latent_storage_dtype: Optional[str] = None,
+ ) -> None:
+ self._codec_to_build = codec
+ self.codec_build_context: Optional[Tuple[bool, bool, bool, bool]] = None
+ self.geometry_validation: Optional[Tuple[Any, ...]] = None
+ self.decode_call: Optional[Tuple[Any, ...]] = None
+ super().__init__(
+ _config(latent_storage_dtype),
+ SimpleNamespace(device=torch.device("cpu")),
+ )
+
+ def build_component_runtime(self) -> Any:
+ scheduler = _Scheduler()
+ return SimpleNamespace(
+ pipeline=SimpleNamespace(scheduler=scheduler),
+ declared_component_names=("scheduler", "vae", "transformer"),
+ materialized_component_names=("scheduler", "vae", "transformer"),
+ override_components={},
+ resolve_component_names=lambda names: (
+ ["transformer"] if names == ["transformer_alias"] else list(names or ())
+ ),
+ )
+
+ def load_pipeline(self) -> Any:
+ raise AssertionError("build_component_runtime owns this test pipeline")
+
+ def load_scheduler(self) -> Any:
+ return self.pipeline.scheduler
+
+ def build_output_state_codec(self) -> Optional[_Codec]:
+ self.codec_build_context = (
+ hasattr(self, "component_runtime"),
+ hasattr(self, "pipeline"),
+ hasattr(self, "scheduler_group"),
+ self.model_args.target_components == ["transformer"],
+ )
+ return self._codec_to_build
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ self.geometry_validation = (media_batch, condition, encoded)
+
+ def _init_target_module_map(self) -> Any:
+ return {}
+
+ def _freeze_components(self) -> None:
+ pass
+
+ def _mix_precision(self) -> None:
+ pass
+
+ def decode_latents(
+ self,
+ latents: torch.Tensor,
+ height: int,
+ output_type: str = "pil",
+ ) -> torch.Tensor:
+ self.decode_call = (latents, height, output_type)
+ return latents
+
+ def inference(self, **kwargs: Any) -> Any:
+ return []
+
+ def forward(self, **kwargs: Any) -> Any:
+ return None
+
+
+class _OnlineOnlyAdapter(_LifecycleAdapter):
+ pipeline_io_contract = None
+
+
+class _DefaultGeometryAdapter(_LifecycleAdapter):
+ _validate_encoded_output_geometry = BaseAdapter._validate_encoded_output_geometry
+
+
+def test_codec_build_runs_after_component_and_scheduler_lifecycle() -> None:
+ codec = _Codec()
+
+ adapter = _LifecycleAdapter(codec)
+
+ assert adapter.codec_build_context == (True, True, True, True)
+ assert adapter.output_state_codec is codec
+ assert adapter.output_state_encoding_modules == ("vae",)
+
+
+def test_codec_build_must_remain_declaration_only() -> None:
+ class MaterializingCodecAdapter(_LifecycleAdapter):
+ def build_output_state_codec(self) -> Optional[_Codec]:
+ self.component_runtime.materialized_component_names += ("late_component",)
+ return self._codec_to_build
+
+ with pytest.raises(RuntimeError, match=r"declaration-only.*cannot materialize"):
+ MaterializingCodecAdapter(_Codec())
+
+
+def test_contract_without_codec_preserves_online_adapter_construction() -> None:
+ adapter = _LifecycleAdapter(None)
+
+ assert adapter.output_state_codec is None
+ assert adapter.output_state_encoding_modules == ()
+ with pytest.raises(RuntimeError, match=r"does not provide an output-state codec"):
+ adapter.encode_output_state(_image_batch(1), {})
+
+
+def test_known_codec_blocker_is_actionable_at_direct_encode_boundary() -> None:
+ class KnownUnavailableAdapter(_OnlineOnlyAdapter):
+ output_state_codec_unavailable_reason = (
+ "Source conditioning pixels are not retained; extend the condition contract."
+ )
+
+ adapter = KnownUnavailableAdapter(None)
+
+ with pytest.raises(
+ NotImplementedError,
+ match=r"KnownUnavailableAdapter.*Source conditioning pixels.*extend",
+ ):
+ adapter.encode_output_state(_image_batch(1), {})
+
+
+@pytest.mark.parametrize("reason", ["", " ", 3])
+def test_codec_blocker_reason_must_be_a_non_empty_string(reason: Any) -> None:
+ class InvalidReasonAdapter(_LifecycleAdapter):
+ output_state_codec_unavailable_reason = reason
+
+ with pytest.raises(TypeError, match=r"non-empty string or None"):
+ InvalidReasonAdapter(None)
+
+
+def test_codec_and_unavailable_reason_cannot_be_declared_together() -> None:
+ class StaleBlockerAdapter(_LifecycleAdapter):
+ output_state_codec_unavailable_reason = "Codec is not implemented."
+
+ with pytest.raises(ValueError, match=r"built an output-state codec.*stale blocker"):
+ StaleBlockerAdapter(_Codec())
+
+
+def test_online_only_adapter_fails_clearly_when_encoding_is_requested() -> None:
+ adapter = _OnlineOnlyAdapter(None)
+
+ with pytest.raises(RuntimeError, match=r"does not declare pipeline_io_contract"):
+ adapter.encode_output_state(_image_batch(1), {})
+
+
+def test_codec_without_pipeline_contract_fails_during_init() -> None:
+ with pytest.raises(ValueError, match=r"codec without declaring pipeline_io_contract"):
+ _OnlineOnlyAdapter(_Codec())
+
+
+def test_invalid_pipeline_contract_type_fails_during_init() -> None:
+ class InvalidContractAdapter(_LifecycleAdapter):
+ pipeline_io_contract = "image" # type: ignore[assignment]
+
+ with pytest.raises(TypeError, match=r"PipelineIOContract or None.*str"):
+ InvalidContractAdapter(None)
+
+
+def test_codec_required_components_must_exist_in_runtime() -> None:
+ with pytest.raises(ValueError, match=r"unavailable adapter components.*missing"):
+ _LifecycleAdapter(_Codec(required_components=("missing",)))
+
+
+def test_public_output_state_wrapper_cannot_be_overridden() -> None:
+ with pytest.raises(TypeError, match=r"must not override BaseAdapter.encode_output_state"):
+
+ class InvalidAdapter(_LifecycleAdapter):
+ def encode_output_state(self, *args: Any, **kwargs: Any) -> Any:
+ return None
+
+ with pytest.raises(TypeError, match=r"must not override BaseAdapter.decode_output_state"):
+
+ class InvalidDecodeAdapter(_LifecycleAdapter):
+ def decode_output_state(self, *args: Any, **kwargs: Any) -> Any:
+ return None
+
+
+def test_multi_component_decoder_extends_the_protected_hook() -> None:
+ class MultiComponentDecodeAdapter(_LifecycleAdapter):
+ def _decode_output_state(
+ self,
+ encoded: EncodedOutputState,
+ *,
+ output_type: str,
+ ) -> Any:
+ return encoded.clean_state.component_names, output_type
+
+ adapter = MultiComponentDecodeAdapter(_Codec())
+ encoded = _encoded_image_batch(1, component_name="video")
+
+ assert adapter.decode_output_state(encoded, output_type="pt") == (("video",), "pt")
+
+
+def test_encode_output_state_validates_invokes_no_grad_and_applies_storage_dtype() -> None:
+ codec = _Codec()
+ adapter = _LifecycleAdapter(codec, latent_storage_dtype="fp16")
+ media_batch = _image_batch(2)
+ condition = {"prompt_embeds": torch.zeros(2, 4)}
+ generator = torch.Generator().manual_seed(7)
+
+ encoded = adapter.encode_output_state(media_batch, condition, generator)
+
+ assert codec.calls == 1
+ assert codec.grad_enabled is False
+ assert codec.received is not None
+ assert codec.received[0] is media_batch
+ assert codec.received[1] is condition
+ assert codec.received[2] is generator
+ assert encoded.clean_state.components["latent"].dtype is torch.float16
+ assert adapter.geometry_validation is not None
+ assert adapter.geometry_validation[0] is media_batch
+ assert adapter.geometry_validation[1] is condition
+ assert adapter.geometry_validation[2] is encoded
+
+
+def test_encode_output_state_rejects_candidate_before_invoking_codec() -> None:
+ codec = _Codec()
+ adapter = _LifecycleAdapter(codec)
+ wrong_type = ((_DecodedMedia(type="video", payload=torch.zeros(1)),),)
+
+ with pytest.raises(ValueError, match=r"expected.*type 'image'.*'video'"):
+ adapter.encode_output_state(wrong_type, {})
+
+ assert codec.calls == 0
+ assert adapter.geometry_validation is None
+
+
+@pytest.mark.parametrize(
+ ("condition", "generator", "message"),
+ [
+ ([], None, r"condition to be Mapping"),
+ ({}, object(), r"generator to be torch.Generator or None"),
+ ],
+)
+def test_encode_output_state_rejects_invalid_wrapper_arguments(
+ condition: Any,
+ generator: Any,
+ message: str,
+) -> None:
+ codec = _Codec()
+ adapter = _LifecycleAdapter(codec)
+
+ with pytest.raises(TypeError, match=message):
+ adapter.encode_output_state(_image_batch(1), condition, generator)
+
+ assert codec.calls == 0
+
+
+def test_encode_output_state_validates_codec_result_before_geometry_hook() -> None:
+ codec = _Codec(result=_encoded_image_batch(1, component_name="other"))
+ adapter = _LifecycleAdapter(codec)
+
+ with pytest.raises(ValueError, match=r"component order \('latent',\).+\('other',\)"):
+ adapter.encode_output_state(_image_batch(1), {})
+
+ assert adapter.geometry_validation is None
+
+
+def test_encode_output_state_does_not_hide_attached_codec_tensor_during_cast() -> None:
+ attached = torch.ones(1, 2, requires_grad=True)
+ codec = _Codec(result=_encoded_image_batch(1, tensor=attached))
+ adapter = _LifecycleAdapter(codec, latent_storage_dtype="fp16")
+
+ with pytest.raises(ValueError, match=r"detached.*clean_state component 'latent'"):
+ adapter.encode_output_state(_image_batch(1), {})
+
+
+def test_decode_output_state_routes_context_through_existing_decoder_signature() -> None:
+ adapter = _LifecycleAdapter(_Codec())
+ encoded = _encoded_image_batch(1)
+
+ decoded = adapter.decode_output_state(encoded, output_type="pt")
+
+ latent = encoded.clean_state.components["latent"]
+ assert decoded is latent
+ assert adapter.decode_call == (latent, 8, "pt")
+
+
+@pytest.mark.parametrize(
+ ("encoded", "output_type", "message"),
+ [
+ (object(), "pil", r"expected encoded output state"),
+ (_encoded_image_batch(1), 3, r"expected output_type to be str"),
+ (_encoded_image_batch(1), "latent", r"expected output_type in"),
+ (
+ _encoded_image_batch(1, component_name="video"),
+ "pil",
+ r"exactly one 'latent' component",
+ ),
+ ],
+)
+def test_decode_output_state_validates_shared_boundary_arguments(
+ encoded: Any,
+ output_type: Any,
+ message: str,
+) -> None:
+ adapter = _LifecycleAdapter(_Codec())
+
+ with pytest.raises((TypeError, ValueError), match=message):
+ adapter.decode_output_state(encoded, output_type=output_type)
+
+ assert adapter.decode_call is None
+
+
+def test_default_geometry_hook_rejects_self_reported_codec_geometry() -> None:
+ adapter = _DefaultGeometryAdapter(_Codec())
+
+ with pytest.raises(
+ NotImplementedError,
+ match=r"must override _validate_encoded_output_geometry.*configured",
+ ):
+ adapter.encode_output_state(_image_batch(1), {})
From ae7f1b80c94eff4dce340d51f668ee93a91ad56f Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:13:20 +0800
Subject: [PATCH 06/76] refactor(training): unify online and offline
acquisition
---
src/flow_factory/hparams/args.py | 65 +++-
.../hparams/training_args/_base.py | 112 +++++--
.../hparams/training_args/dmd2.py | 6 +
src/flow_factory/hparams/training_args/opd.py | 8 +-
.../hparams/training_args/tdm_r1.py | 3 +
src/flow_factory/trainers/__init__.py | 12 +
src/flow_factory/trainers/abc.py | 314 +++++++++++++++---
.../trainers/distillation/dmd2.py | 5 +
.../trainers/distillation/opd/trainer.py | 7 +-
src/flow_factory/trainers/distillation/tdm.py | 5 +
.../trainers/distillation/tdm_r1.py | 2 +
src/flow_factory/trainers/execution.py | 314 ++++++++++++++++++
src/flow_factory/trainers/loader.py | 41 ++-
tests/hparams/test_execution_contracts.py | 76 +++++
tests/trainers/test_execution_kernel.py | 255 ++++++++++++++
15 files changed, 1135 insertions(+), 90 deletions(-)
create mode 100644 src/flow_factory/trainers/execution.py
create mode 100644 tests/hparams/test_execution_contracts.py
create mode 100644 tests/trainers/test_execution_kernel.py
diff --git a/src/flow_factory/hparams/args.py b/src/flow_factory/hparams/args.py
index 971a433cb..c7c205398 100644
--- a/src/flow_factory/hparams/args.py
+++ b/src/flow_factory/hparams/args.py
@@ -30,6 +30,7 @@
import yaml
+from ..contracts.execution import AcquisitionMode, ExecutionContract, FeedbackMode
from ..utils.dist import get_world_size
from ..utils.logger_utils import setup_logger
from .abc import ArgABC
@@ -212,12 +213,6 @@ def _validate_multirole_training_contract(self) -> None:
"trainer_type='tdm-r1' requires complete reward groups with "
f"group_size >= 2, received group_size={training_args.group_size}."
)
- elif isinstance(training_args, DMD2TrainingArguments) and self.reward_args:
- raise ValueError(
- f"trainer_type={training_args.trainer_type!r} does not accept training "
- f"rewards, but received {len(self.reward_args)} reward configuration(s)."
- )
-
if (
isinstance(training_args, TDMTrainingArguments)
and self.scheduler_args.dynamics_type != "ODE"
@@ -228,6 +223,57 @@ def _validate_multirole_training_contract(self) -> None:
f"dynamics_type={self.scheduler_args.dynamics_type!r}."
)
+ def _get_execution_contract(self) -> ExecutionContract:
+ """Return the immutable algorithm contract used by configuration gating."""
+ contract = getattr(type(self.training_args), "execution_contract", None)
+ if not isinstance(contract, ExecutionContract):
+ raise TypeError(
+ f"training arguments {type(self.training_args).__name__}.execution_contract "
+ f"must be ExecutionContract, got {type(contract).__name__}: {contract!r}"
+ )
+ return contract
+
+ def _validate_training_feedback_contract(self) -> None:
+ """Reject runtime training rewards when the algorithm declares no feedback."""
+ if self._get_execution_contract().feedback is FeedbackMode.NONE and self.reward_args:
+ raise ValueError(
+ f"trainer_type={self.training_args.trainer_type!r} does not accept training "
+ f"rewards, but received {len(self.reward_args)} reward configuration(s). "
+ "Use eval_rewards for evaluation-only monitoring."
+ )
+
+ def _validate_dataset_acquisition_contract(self) -> None:
+ """Keep complete data epochs independent from grouped generation geometry."""
+ if self._get_execution_contract().acquisition is not AcquisitionMode.DATASET:
+ return
+ if not self.data_args.training_datasets:
+ raise ValueError(
+ "dataset acquisition requires at least one enabled training source under "
+ "data.datasets; the legacy prompt-only data.dataset_dir path cannot carry "
+ "demonstration or preference supervision"
+ )
+ if not self.data_args.enable_preprocess:
+ raise ValueError(
+ "dataset acquisition requires data.enable_preprocess=True so model-input "
+ "conditions use the input-only preprocessing cache"
+ )
+ if self.data_args.sampler_type != "auto":
+ raise ValueError(
+ "dataset acquisition uses torch.utils.data.DistributedSampler directly; "
+ f"data.sampler_type must remain 'auto', received "
+ f"{self.data_args.sampler_type!r}"
+ )
+ bad_weights = [
+ (dataset.name, dataset.train.weight)
+ for dataset in self.data_args.training_datasets
+ if dataset.train is not None and dataset.train.weight != 1
+ ]
+ if bad_weights:
+ raise ValueError(
+ "dataset acquisition requires train.weight=1 so one epoch remains one "
+ f"complete dataloader traversal; received {bad_weights!r}"
+ )
+
def _validate_dmd2_batch_geometry(self) -> None:
"""Require one generator step per distillation outer iteration."""
training_args = self.training_args
@@ -294,7 +340,9 @@ def __post_init__(self):
self.log_args.run_name = f"{self.model_args.model_type}_{self.model_args.finetune_type}_{self.training_args.trainer_type}_{time_stamp}"
self._synthesize_default_optimizer_args()
+ self._validate_training_feedback_contract()
self._validate_dataset_routing()
+ self._validate_dataset_acquisition_contract()
# Resolve `RewardArguments.applicable_datasets is None` -> concrete
# list of applicable dataset names. Must run AFTER validation (so the
# unknown-name check is against the user's raw input, not the
@@ -325,6 +373,8 @@ def __post_init__(self):
self._validate_teacher_sources()
self._resolve_scheduler_sde_defaults()
self._validate_multirole_training_contract()
+ if self._get_execution_contract().acquisition is AcquisitionMode.DATASET:
+ return
self._resolve_sampler_type()
self._align_batch_geometry()
self._adjust_gradient_accumulation()
@@ -483,7 +533,8 @@ def _check_side(reward_args, source_names, side: str) -> None:
f"or drop the dataset entry from `data.datasets`."
)
- _check_side(self.reward_args, train_names, side="Training")
+ if self._get_execution_contract().feedback is FeedbackMode.RUNTIME_REWARD:
+ _check_side(self.reward_args, train_names, side="Training")
_check_side(self.eval_reward_args, eval_names, side="Eval")
def _validate_teacher_sources(self) -> None:
diff --git a/src/flow_factory/hparams/training_args/_base.py b/src/flow_factory/hparams/training_args/_base.py
index 70abfffdc..e47f204e4 100644
--- a/src/flow_factory/hparams/training_args/_base.py
+++ b/src/flow_factory/hparams/training_args/_base.py
@@ -15,10 +15,15 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Literal, Mapping, Optional, Tuple, Union
import yaml
+from ...contracts.execution import (
+ ONLINE_EXECUTION_CONTRACT,
+ AcquisitionMode,
+ ExecutionContract,
+)
from ...utils.dist import get_world_size
from ...utils.logger_utils import setup_logger
from ..abc import ArgABC
@@ -122,6 +127,8 @@ def to_dict(self) -> dict[str, Any]:
class TrainingArguments(ArgABC):
r"""Base training arguments shared across all algorithms."""
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_EXECUTION_CONTRACT
+
# --- Trainer type ---
trainer_type: str = field(
default="grpo",
@@ -159,7 +166,9 @@ class TrainingArguments(ArgABC):
default=None,
metadata={
"help": (
- "Maximum number of outer training epochs (counter `epoch` runs 0 .. max_epochs-1). "
+ "Maximum acquisition cycles: rollout iterations for generation and complete "
+ "dataloader epochs for dataset acquisition. The compatibility `epoch` counter "
+ "runs from 0 through max_epochs - 1. "
"None or a negative value means no limit (train until interrupted)."
),
},
@@ -347,14 +356,54 @@ def __post_init__(self):
self.height, self.width = self.resolution
- # --- Batch size calculation ---
- # NOTE: M alignment and derived quantities (num_batches_per_epoch,
- # gradient_accumulation_steps) are computed in Arguments._align_batch_geometry()
- # because the correct alignment strategy depends on the resolved sampler type,
- # which requires cross-component information (data_args, reward_args) only
- # available at the Arguments level.
- # Placeholder values are set here so the fields exist; they will be
- # overwritten by _align_batch_geometry() before any consumer reads them.
+ self._initialize_batch_geometry()
+
+ # --- Optimizer defaults ---
+ # Explicit float() casts guard against scientific-notation values (e.g. 1e-4)
+ # arriving as strings from non-standard config sources or future CLI overrides.
+ self.adam_betas = (float(self.adam_betas[0]), float(self.adam_betas[1]))
+ self.adam_weight_decay = float(self.adam_weight_decay)
+ self.adam_epsilon = float(self.adam_epsilon)
+ self.max_grad_norm = float(self.max_grad_norm)
+
+ if self.learning_rate is None:
+ if "lora" in self.trainer_type.lower():
+ self.learning_rate = 1e-4
+ else:
+ self.learning_rate = 1e-5
+ logger.info(
+ f"`learning_rate` is not set, using default {self.learning_rate} for `{self.trainer_type}` training."
+ )
+ else:
+ self.learning_rate = float(self.learning_rate)
+
+ def _initialize_batch_geometry(self) -> None:
+ """Initialize acquisition-specific batch fields without mixing samplers."""
+ contract = getattr(type(self), "execution_contract", None)
+ if not isinstance(contract, ExecutionContract):
+ raise TypeError(
+ f"training arguments {type(self).__name__}.execution_contract must be "
+ f"ExecutionContract, got {type(contract).__name__}: {contract!r}"
+ )
+ if contract.acquisition is AcquisitionMode.DATASET:
+ accumulation_steps = self.gradient_accumulation_steps
+ if type(accumulation_steps) is not int:
+ raise TypeError(
+ "dataset acquisition requires explicit integer "
+ "gradient_accumulation_steps >= 1, received "
+ f"{type(accumulation_steps).__name__}: {accumulation_steps!r}"
+ )
+ if accumulation_steps < 1:
+ raise ValueError(
+ "dataset acquisition requires gradient_accumulation_steps >= 1, "
+ f"received {accumulation_steps}"
+ )
+ self._manual_gradient_accumulation_steps = True
+ self.num_batches_per_epoch = 0
+ return
+
+ # Grouped generation geometry is finalized by Arguments after the sampler
+ # strategy is resolved. These are only the pre-resolution placeholders.
world_size = get_world_size()
logger.info(f"World Size: {world_size}")
@@ -376,25 +425,6 @@ def __post_init__(self):
f"got {self.gradient_accumulation_steps}."
)
- # --- Optimizer defaults ---
- # Explicit float() casts guard against scientific-notation values (e.g. 1e-4)
- # arriving as strings from non-standard config sources or future CLI overrides.
- self.adam_betas = (float(self.adam_betas[0]), float(self.adam_betas[1]))
- self.adam_weight_decay = float(self.adam_weight_decay)
- self.adam_epsilon = float(self.adam_epsilon)
- self.max_grad_norm = float(self.max_grad_norm)
-
- if self.learning_rate is None:
- if "lora" in self.trainer_type.lower():
- self.learning_rate = 1e-4
- else:
- self.learning_rate = 1e-5
- logger.info(
- f"`learning_rate` is not set, using default {self.learning_rate} for `{self.trainer_type}` training."
- )
- else:
- self.learning_rate = float(self.learning_rate)
-
def compute_gradient_accumulation_steps(
self,
num_batches_per_epoch: int,
@@ -446,6 +476,30 @@ def gradient_checkpointing_enabled(self) -> bool:
"""Return whether the normalized model-level policy checkpoints any block."""
return gradient_checkpointing_enabled(self.enable_gradient_checkpointing)
+ @classmethod
+ def from_dict(cls, args_dict: Mapping[str, Any]):
+ """Parse fields while keeping execution semantics algorithm-owned.
+
+ Args:
+ args_dict: User training configuration.
+
+ Returns:
+ Resolved training arguments instance.
+ """
+ if not isinstance(args_dict, Mapping):
+ raise TypeError(
+ "expected training arguments as a mapping, received "
+ f"{type(args_dict).__name__}: {args_dict!r}"
+ )
+ explicit_extras = args_dict.get("extra_kwargs")
+ if "execution_contract" in args_dict or (
+ isinstance(explicit_extras, Mapping) and "execution_contract" in explicit_extras
+ ):
+ raise ValueError(
+ "train.execution_contract is selected by trainer_type and cannot be configured"
+ )
+ return super().from_dict(dict(args_dict))
+
def to_dict(self) -> dict[str, Any]:
values = super().to_dict()
values["enable_gradient_checkpointing"] = serialize_gradient_checkpointing_policy(
diff --git a/src/flow_factory/hparams/training_args/dmd2.py b/src/flow_factory/hparams/training_args/dmd2.py
index 6a71904cf..cd85b0c8d 100644
--- a/src/flow_factory/hparams/training_args/dmd2.py
+++ b/src/flow_factory/hparams/training_args/dmd2.py
@@ -20,6 +20,10 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar, Optional, Tuple, cast
+from ...contracts.execution import (
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ ExecutionContract,
+)
from ..optimizer_args import AdamWOptimizerArguments
from ._base import TrainingArguments
@@ -49,6 +53,8 @@ def _finite_float(value: object, field_name: str, *, allow_zero: bool) -> float:
class DMD2TrainingArguments(TrainingArguments):
"""Configure data-free DMD2 distribution matching."""
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
+
gradient_step_per_epoch: int = field(
default=1,
metadata={"help": "DMD2 requires one generator optimizer step per rollout."},
diff --git a/src/flow_factory/hparams/training_args/opd.py b/src/flow_factory/hparams/training_args/opd.py
index ed4c2b3d6..f8482eaac 100644
--- a/src/flow_factory/hparams/training_args/opd.py
+++ b/src/flow_factory/hparams/training_args/opd.py
@@ -23,8 +23,12 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, List, Literal, Optional, Tuple, Union
+from ...contracts.execution import (
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ ExecutionContract,
+)
from ..abc import ArgABC
from ._base import TrainingArguments, _standardize_timestep_range
@@ -108,6 +112,8 @@ class DiffusionOPDTrainingArguments(TrainingArguments):
loss remains divided by the scheduler's transition variance.
"""
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
+
teachers: List[TeacherConfig] = field(
default_factory=list,
metadata={"help": "List of teacher configs; each maps a LoRA checkpoint -> dataset(s)."},
diff --git a/src/flow_factory/hparams/training_args/tdm_r1.py b/src/flow_factory/hparams/training_args/tdm_r1.py
index 493457622..3755c4912 100644
--- a/src/flow_factory/hparams/training_args/tdm_r1.py
+++ b/src/flow_factory/hparams/training_args/tdm_r1.py
@@ -18,6 +18,7 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, ClassVar, Literal, Tuple
+from ...contracts.execution import ONLINE_EXECUTION_CONTRACT, ExecutionContract
from ..optimizer_args import AdamWOptimizerArguments
from .dmd2 import _finite_float
from .tdm import TDMTrainingArguments
@@ -31,6 +32,8 @@
class TDMR1TrainingArguments(TDMTrainingArguments):
"""Configure TDM-R1 with a learned surrogate and frozen reference."""
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_EXECUTION_CONTRACT
+
advantage_aggregation: Literal["sum", "gdpo"] = "gdpo"
tdm_weight: float = 0.3
surrogate_preference_beta: float = 1.0
diff --git a/src/flow_factory/trainers/__init__.py b/src/flow_factory/trainers/__init__.py
index 4fcf1f22a..d1db575ed 100644
--- a/src/flow_factory/trainers/__init__.py
+++ b/src/flow_factory/trainers/__init__.py
@@ -18,6 +18,13 @@
"""
from .abc import BaseTrainer
+from .execution import (
+ AcquisitionDriver,
+ DatasetAcquisitionDriver,
+ GenerationAcquisitionDriver,
+ TrainingProgress,
+ build_acquisition_driver,
+)
from .loader import load_trainer
from .registry import get_trainer_class, list_registered_trainers
@@ -26,6 +33,11 @@
__all__ = [
"BaseTrainer",
+ "AcquisitionDriver",
+ "DatasetAcquisitionDriver",
+ "GenerationAcquisitionDriver",
+ "TrainingProgress",
+ "build_acquisition_driver",
"get_trainer_class",
"list_registered_trainers",
"load_trainer",
diff --git a/src/flow_factory/trainers/abc.py b/src/flow_factory/trainers/abc.py
index 6c9392b65..5d94ef75c 100644
--- a/src/flow_factory/trainers/abc.py
+++ b/src/flow_factory/trainers/abc.py
@@ -15,7 +15,7 @@
# src/flow_factory/trainers/abc.py
import json
import os
-from abc import ABC, abstractmethod
+from abc import ABC
from collections import defaultdict
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass, replace
@@ -45,6 +45,12 @@
from ..acceleration import BaseAccelerator, build_accelerator, validate_accelerator
from ..advantage import AdvantageProcessor
+from ..contracts.execution import (
+ ONLINE_EXECUTION_CONTRACT,
+ AcquisitionMode,
+ ExecutionContract,
+ FeedbackMode,
+)
from ..data_utils.dataset import METADATA_COLUMN
from ..data_utils.loader import (
get_eval_dataloaders,
@@ -75,6 +81,11 @@
from ..utils.logger_utils import setup_logger
from ..utils.noise_schedule import TimeSampler
from .common.sample_prefetch import iter_prefetched_batches
+from .execution import (
+ AcquisitionDriver,
+ TrainingProgress,
+ build_acquisition_driver,
+)
from .multirole import (
MultiRoleBackendValidationMixin,
MultiRoleCheckpointingMixin,
@@ -102,6 +113,7 @@ class BaseTrainer(MultiRoleCheckpointingMixin, MultiRoleBackendValidationMixin,
# 'decoupled' / 'distillation' trainers may use them. Concrete trainers
# MUST override this; leaving it None disables lossy acceleration.
paradigm: ClassVar[Optional[Literal["coupled", "decoupled", "distillation"]]] = None
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_EXECUTION_CONTRACT
def __init__(
self,
@@ -118,6 +130,7 @@ def __init__(
self.training_args = config.training_args
self.eval_args = config.eval_args
+ self._validate_execution_contract()
self.reward_args = config.reward_args
self.eval_reward_args = (
@@ -125,9 +138,13 @@ def __init__(
) # If `eval_reward_args` is not given, use `reward_args`
self.adapter = adapter
+ self._validate_adapter_execution_contract()
self.load_coordinator = ModelLoadCoordinator(adapter, accelerator)
- self.epoch = 0
- self.step = 0
+ self.progress = TrainingProgress()
+ self.acquisition_driver: AcquisitionDriver = build_acquisition_driver(
+ type(self).execution_contract
+ )
+ self._validate_execution_hooks()
self._initialization()
self._initialize_snapshots()
@@ -154,15 +171,144 @@ def show_progress_bar(self) -> bool:
"""Whether to show tqdm progress bars."""
return self.log_args.verbose and self.accelerator.is_local_main_process
+ @property
+ def cycle_index(self) -> int:
+ """Return the completed acquisition-cycle count for this trainer."""
+ return self._get_progress().cycle_index(type(self).execution_contract.acquisition)
+
+ def _get_progress(self) -> TrainingProgress:
+ """Return typed progress for initialized and lightweight test trainers."""
+ progress = self.__dict__.get("progress")
+ if progress is None:
+ progress = TrainingProgress()
+ self.__dict__["progress"] = progress
+ if not isinstance(progress, TrainingProgress):
+ raise TypeError(
+ "expected trainer progress to be TrainingProgress, received "
+ f"{type(progress).__name__}: {progress!r}"
+ )
+ return progress
+
+ @property
+ def epoch(self) -> int:
+ """Return the compatibility alias for the active acquisition cycle.
+
+ Generated acquisition maps this alias to rollout iterations. Dataset
+ acquisition maps it to complete dataloader traversals.
+ """
+ return self.cycle_index
+
+ @epoch.setter
+ def epoch(self, value: int) -> None:
+ """Set the compatibility acquisition-cycle alias.
+
+ Args:
+ value: Non-negative completed-cycle count.
+ """
+ progress = self._get_progress()
+ if type(self).execution_contract.acquisition is AcquisitionMode.GENERATION:
+ self.progress = replace(progress, rollout_iteration=value)
+ else:
+ self.progress = replace(progress, data_epoch=value)
+
+ @property
+ def step(self) -> int:
+ """Return the completed primary optimizer-step count."""
+ return self._get_progress().optimizer_step
+
+ @step.setter
+ def step(self, value: int) -> None:
+ """Set the completed primary optimizer-step count.
+
+ Args:
+ value: Non-negative number of completed optimizer updates.
+ """
+ self.progress = replace(self._get_progress(), optimizer_step=value)
+
+ def _validate_execution_hooks(self) -> None:
+ """Require the optimization hook selected by acquisition mode."""
+ acquisition = type(self).execution_contract.acquisition
+ if (
+ acquisition is AcquisitionMode.GENERATION
+ and type(self).optimize is BaseTrainer.optimize
+ ):
+ raise TypeError(
+ f"generation trainer {type(self).__name__} must override optimize(samples)"
+ )
+ if (
+ acquisition is AcquisitionMode.DATASET
+ and type(self).optimize_batch is BaseTrainer.optimize_batch
+ ):
+ raise TypeError(
+ f"dataset trainer {type(self).__name__} must override optimize_batch(batch)"
+ )
+
+ def _validate_execution_contract(self) -> None:
+ """Require trainer runtime and arguments to declare equal semantics."""
+ type(self).validate_training_arguments_contract(self.training_args)
+
+ @classmethod
+ def validate_training_arguments_contract(cls, training_args: Any) -> None:
+ """Validate algorithm arguments before heavyweight initialization.
+
+ Args:
+ training_args: Resolved algorithm-specific training arguments.
+ """
+ trainer_contract = cls.execution_contract
+ arguments_contract = getattr(type(training_args), "execution_contract", None)
+ if not isinstance(trainer_contract, ExecutionContract):
+ raise TypeError(
+ f"trainer {cls.__name__}.execution_contract must be ExecutionContract, "
+ f"got {type(trainer_contract).__name__}: {trainer_contract!r}"
+ )
+ if not isinstance(arguments_contract, ExecutionContract):
+ raise TypeError(
+ f"training arguments {type(training_args).__name__}.execution_contract "
+ f"must be ExecutionContract, got {type(arguments_contract).__name__}: "
+ f"{arguments_contract!r}"
+ )
+ if trainer_contract != arguments_contract:
+ raise ValueError(
+ f"execution contract mismatch for trainer {cls.__name__} and "
+ f"training arguments {type(training_args).__name__}: "
+ f"trainer={trainer_contract!r}, arguments={arguments_contract!r}"
+ )
+
+ @classmethod
+ def validate_adapter_class_execution_contract(cls, adapter_cls: type) -> None:
+ """Reject statically unsupported dataset acquisition before model loading.
+
+ Args:
+ adapter_cls: Resolved model adapter class.
+ """
+ if cls.execution_contract.acquisition is not AcquisitionMode.DATASET:
+ return
+ if not isinstance(adapter_cls, type) or not issubclass(adapter_cls, BaseAdapter):
+ raise TypeError(
+ f"dataset trainer {cls.__name__} requires a BaseAdapter subclass, "
+ f"received {adapter_cls!r}"
+ )
+ validator = getattr(adapter_cls, "validate_offline_output_capability", None)
+ if not callable(validator):
+ raise TypeError(
+ f"adapter {adapter_cls.__name__} must define "
+ "validate_offline_output_capability() for dataset acquisition"
+ )
+ validator()
+
+ def _validate_adapter_execution_contract(self) -> None:
+ """Validate the realized adapter on the selected acquisition path."""
+ type(self).validate_adapter_class_execution_contract(type(self.adapter))
+
def _initialize_snapshots(self) -> None:
"""Initialize optional trainer-owned parameter snapshots before state resume."""
def should_continue_training(self) -> bool:
- """Outer epoch loop: continue unless a finite ``max_epochs`` has been reached."""
+ """Continue until the active acquisition cycle reaches ``max_epochs``."""
m = self.training_args.max_epochs
if m is None or m < 0:
return True
- return self.epoch < m
+ return self.cycle_index < m
def accumulate_gradients(self):
"""Context manager for gradient accumulation over the single prepared root.
@@ -322,11 +468,14 @@ def _init_dataloader(
device=self.accelerator.device,
)
- dataloader, train_dataloaders_by_source = get_train_dataloader(
- config=self.config,
- accelerator=self.accelerator,
- preprocess_func=self.adapter.preprocess_func,
- )
+ build_train_dataloader = getattr(self, "_build_train_dataloader", None)
+ if build_train_dataloader is None:
+ # A few lifecycle tests intentionally call this shared method on a
+ # lightweight structural host. Preserve that supported boundary while
+ # real trainer subclasses continue to override the acquisition seam.
+ dataloader, train_dataloaders_by_source = BaseTrainer._build_train_dataloader(self)
+ else:
+ dataloader, train_dataloaders_by_source = build_train_dataloader()
self.train_dataloaders_by_source: Dict[str, DataLoader] = train_dataloaders_by_source
eval_dataloaders = get_eval_dataloaders(
@@ -344,6 +493,25 @@ def _init_dataloader(
return dataloader, eval_dataloaders
+ def _build_train_dataloader(
+ self,
+ ) -> Tuple[Optional[Union[DataLoader, "MultiSourceTrainDataLoader"]], Dict[str, DataLoader]]:
+ """Build the acquisition-specific training dataloader.
+
+ Returns:
+ Training loader and its per-source loader mapping.
+
+ Note:
+ The default preserves grouped online rollout loading. Dataset-based
+ trainers override this hook with the finite offline loader builder; the
+ surrounding preprocessing lifecycle remains shared.
+ """
+ return get_train_dataloader(
+ config=self.config,
+ accelerator=self.accelerator,
+ preprocess_func=self.adapter.preprocess_func,
+ )
+
def _init_optimizer(self) -> torch.optim.Optimizer:
"""Build the single optimizer root, its groups ordered and tagged by role.
@@ -593,6 +761,14 @@ def _load_inference_components(self, trainable_module_names: List[str]):
modules_to_load = list(self.adapter.inference_modules)
+ execution_contract = getattr(
+ type(self),
+ "execution_contract",
+ ONLINE_EXECUTION_CONTRACT,
+ )
+ if execution_contract.acquisition is AcquisitionMode.DATASET:
+ modules_to_load.extend(self.adapter.output_state_encoding_modules)
+
if not self.config.data_args.enable_preprocess:
modules_to_load.extend(self.adapter.preprocessing_modules)
@@ -880,36 +1056,77 @@ def _patched_dtype(self):
def start(self) -> None:
"""Run the training loop until the configured budget is exhausted.
- Every algorithm drives the same epoch: reseed, save on ``save_freq``,
- evaluate on ``eval_freq``, then sample, score, optimize, and step EMA.
- Only the middle of that sequence is algorithm-specific, so the loop lives
- here and the variation is expressed through
- :meth:`sampling_context`, :meth:`_run_training_step` and
- :meth:`_after_optimizer_step` rather than by restating the loop.
+ Generation acquisition retains the existing pre-rollout checkpoint,
+ evaluation, and cycle-level EMA cadence. Dataset acquisition exhausts one
+ finite official distributed loader before incrementing ``data_epoch`` and
+ publishing post-epoch boundaries. Optimizer progress remains independent.
"""
+ contract = type(self).execution_contract
while self.should_continue_training():
- self.adapter.set_trajectory_seed(self.epoch + self.training_args.seed)
-
- if (
- self.log_args.save_freq > 0
- and self.epoch % self.log_args.save_freq == 0
- and self.log_args.save_dir
- ):
- save_dir = os.path.join(
- self.log_args.save_dir,
- str(self.log_args.run_name),
- "checkpoints",
- )
- self.save_checkpoint(save_dir, epoch=self.epoch)
+ driver = getattr(self, "acquisition_driver", None)
+ if driver is None:
+ driver = build_acquisition_driver(contract)
+ self.acquisition_driver = driver
+ driver.prepare_cycle(
+ self,
+ self._get_progress(),
+ seed=self.training_args.seed,
+ )
- if self.eval_args.eval_freq > 0 and self.epoch % self.eval_args.eval_freq == 0:
- self.evaluate()
+ if contract.acquisition is AcquisitionMode.GENERATION:
+ self._run_periodic_cycle_boundaries()
- self._run_training_step()
+ driver.run_cycle(self, self._get_progress())
- self.adapter.ema_step(step=self.epoch)
+ if contract.acquisition is AcquisitionMode.GENERATION:
+ self.adapter.ema_step(step=self.cycle_index)
self._after_optimizer_step()
- self.epoch += 1
+ self.progress = self._get_progress().advance_acquisition(
+ contract.acquisition,
+ completed=True,
+ )
+
+ if contract.acquisition is AcquisitionMode.DATASET:
+ self._run_periodic_cycle_boundaries()
+
+ def _run_periodic_cycle_boundaries(self) -> None:
+ """Run save and evaluation actions at the completed-cycle index."""
+ if (
+ self.log_args.save_freq > 0
+ and self.cycle_index % self.log_args.save_freq == 0
+ and self.log_args.save_dir
+ ):
+ save_dir = os.path.join(
+ self.log_args.save_dir,
+ str(self.log_args.run_name),
+ "checkpoints",
+ )
+ self.save_checkpoint(save_dir, epoch=self.cycle_index)
+
+ if self.eval_args.eval_freq > 0 and self.cycle_index % self.eval_args.eval_freq == 0:
+ self.evaluate()
+
+ def set_trajectory_seed(self, seed: int) -> None:
+ """Set the adapter seed for one generated acquisition.
+
+ Args:
+ seed: Effective seed for the next generation cycle.
+ """
+ self.adapter.set_trajectory_seed(seed)
+
+ def run_generation_acquisition(self) -> None:
+ """Run one complete generated acquisition and policy update."""
+ self._run_training_step()
+
+ def train_on_dataset_batch(self, batch: Any) -> None:
+ """Run declared feedback and optimization for one dataset batch.
+
+ Args:
+ batch: Collated batch acquired from the finite offline dataloader.
+ """
+ if type(self).execution_contract.feedback is FeedbackMode.RUNTIME_REWARD:
+ self.prepare_feedback(batch)
+ self.optimize_batch(batch)
def _run_training_step(self) -> None:
"""Run one epoch's rollout, feedback and optimization.
@@ -922,7 +1139,8 @@ def _run_training_step(self) -> None:
"""
with self.sampling_context():
samples = self.sample()
- self.prepare_feedback(samples)
+ if type(self).execution_contract.feedback is FeedbackMode.RUNTIME_REWARD:
+ self.prepare_feedback(samples)
self.optimize(samples)
@contextmanager
@@ -982,10 +1200,26 @@ def compute_advantages(
aggregation_func=aggregation_func,
)
- @abstractmethod
- def optimize(self, *args, **kwargs):
- """Update policy model"""
- pass
+ def optimize(self, *args: Any, **kwargs: Any) -> None:
+ """Update a policy from generated examples.
+
+ Args:
+ *args: Algorithm-specific generated-acquisition inputs.
+ **kwargs: Algorithm-specific optimization options.
+ """
+ raise NotImplementedError(
+ f"generation trainer {type(self).__name__} must implement optimize(samples)"
+ )
+
+ def optimize_batch(self, batch: Any) -> None:
+ """Update a policy from one acquired dataset batch.
+
+ Args:
+ batch: Collated offline training batch.
+ """
+ raise NotImplementedError(
+ f"dataset trainer {type(self).__name__} must implement optimize_batch(batch)"
+ )
def _sample_timesteps(
self,
@@ -1091,6 +1325,8 @@ def _apply_optimizer_step(
self.training_args.max_grad_norm,
)
self.optimizer.step()
+ if type(self).execution_contract.acquisition is AcquisitionMode.DATASET:
+ self.adapter.ema_step(step=self.step)
self.optimizer.zero_grad()
self._after_gradient_step()
diff --git a/src/flow_factory/trainers/distillation/dmd2.py b/src/flow_factory/trainers/distillation/dmd2.py
index b1aab96a1..66a507c8d 100644
--- a/src/flow_factory/trainers/distillation/dmd2.py
+++ b/src/flow_factory/trainers/distillation/dmd2.py
@@ -33,6 +33,10 @@
import torch
from accelerate import Accelerator
+from ...contracts.execution import (
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ ExecutionContract,
+)
from ...hparams import Arguments, DMD2TrainingArguments
from ...hparams.training_args.dmd2 import DMD2_DEFAULT_OPTIMIZERS
from ...models.abc import BaseAdapter
@@ -68,6 +72,7 @@ class DMD2Trainer(BaseTrainer):
"""Optimize a deterministic few-step generator without real training data."""
paradigm: ClassVar[Literal["distillation"]] = "distillation"
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
def _optimizer_args_for_role(self, role_name: str):
"""Resolve this role's optimizer, falling back to DMD2's published defaults.
diff --git a/src/flow_factory/trainers/distillation/opd/trainer.py b/src/flow_factory/trainers/distillation/opd/trainer.py
index 1e0848846..6636822f7 100644
--- a/src/flow_factory/trainers/distillation/opd/trainer.py
+++ b/src/flow_factory/trainers/distillation/opd/trainer.py
@@ -50,13 +50,17 @@
import os
from collections import defaultdict
from functools import partial
-from typing import Any, Dict, List, Mapping, Optional, Tuple, Union, cast
+from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Union, cast
import torch
import tqdm as tqdm_
tqdm = partial(tqdm_.tqdm, dynamic_ncols=True)
+from ....contracts.execution import (
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ ExecutionContract,
+)
from ....hparams import DiffusionOPDTrainingArguments
from ....hparams.training_args.opd import resolve_distill_step_band
from ....samples import (
@@ -87,6 +91,7 @@ class DiffusionOPDTrainer(BaseTrainer):
# Distillation paradigm: no reward/advantage stage and rollout log-probs do not
# enter the loss, so lossy rollout acceleration is permitted (constraints.md #7).
paradigm = "distillation"
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
diff --git a/src/flow_factory/trainers/distillation/tdm.py b/src/flow_factory/trainers/distillation/tdm.py
index 1781219e6..2dc42f1c1 100644
--- a/src/flow_factory/trainers/distillation/tdm.py
+++ b/src/flow_factory/trainers/distillation/tdm.py
@@ -23,6 +23,10 @@
import torch
from accelerate import Accelerator
+from ...contracts.execution import (
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ ExecutionContract,
+)
from ...hparams import Arguments, TDMTrainingArguments
from ...hparams.training_args.dmd2 import DMD2_DEFAULT_OPTIMIZERS
from ...models.abc import BaseAdapter
@@ -80,6 +84,7 @@ class TDMTrainer(TDMTrajectoryRuntimeMixin, BaseTrainer):
"""Optimize every boundary of a deterministic few-step generator trajectory."""
paradigm: ClassVar[Literal["distillation"]] = "distillation"
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
def _optimizer_args_for_role(self, role_name: str):
"""Resolve this role's optimizer, falling back to TDM's published defaults.
diff --git a/src/flow_factory/trainers/distillation/tdm_r1.py b/src/flow_factory/trainers/distillation/tdm_r1.py
index 50e46b3ec..4eb978480 100644
--- a/src/flow_factory/trainers/distillation/tdm_r1.py
+++ b/src/flow_factory/trainers/distillation/tdm_r1.py
@@ -22,6 +22,7 @@
import torch
from accelerate import Accelerator
+from ...contracts.execution import ONLINE_EXECUTION_CONTRACT, ExecutionContract
from ...hparams import Arguments, TDMR1TrainingArguments
from ...hparams.training_args.tdm_r1 import TDM_R1_DEFAULT_OPTIMIZERS
from ...models.abc import BaseAdapter
@@ -50,6 +51,7 @@ class TDMR1Trainer(TDMTrainer):
"""Reinforce deterministic TDM trajectories through a frozen-reference surrogate."""
paradigm: ClassVar[Literal["decoupled"]] = "decoupled"
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_EXECUTION_CONTRACT
def _optimizer_args_for_role(self, role_name: str):
"""Resolve this role's optimizer, falling back to TDM-R1's published defaults.
diff --git a/src/flow_factory/trainers/execution.py b/src/flow_factory/trainers/execution.py
new file mode 100644
index 000000000..99885df4a
--- /dev/null
+++ b/src/flow_factory/trainers/execution.py
@@ -0,0 +1,314 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Acquisition drivers and progress state for the unified training kernel."""
+
+from dataclasses import dataclass, replace
+from typing import Any, Protocol
+
+from torch.utils.data import DataLoader, DistributedSampler
+
+from ..contracts.execution import (
+ OFFLINE_EXECUTION_CONTRACT,
+ ONLINE_EXECUTION_CONTRACT,
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ AcquisitionMode,
+ ExecutionContract,
+ FeedbackMode,
+)
+
+
+@dataclass(frozen=True)
+class TrainingProgress:
+ """Track optimizer updates independently from acquisition cycles."""
+
+ optimizer_step: int = 0
+ rollout_iteration: int = 0
+ data_epoch: int = 0
+
+ def __post_init__(self) -> None:
+ """Reject invalid counter state at construction."""
+ _require_non_negative_int(self.optimizer_step, "optimizer_step")
+ _require_non_negative_int(self.rollout_iteration, "rollout_iteration")
+ _require_non_negative_int(self.data_epoch, "data_epoch")
+
+ def cycle_index(self, acquisition: AcquisitionMode) -> int:
+ """Return the completed-cycle count for an acquisition mode.
+
+ Args:
+ acquisition: Algorithm acquisition mode.
+
+ Returns:
+ Completed rollout iterations or complete dataset epochs.
+ """
+ _require_acquisition(acquisition)
+ if acquisition is AcquisitionMode.GENERATION:
+ return self.rollout_iteration
+ return self.data_epoch
+
+ def advance_acquisition(
+ self,
+ acquisition: AcquisitionMode,
+ *,
+ completed: bool,
+ ) -> "TrainingProgress":
+ """Advance one acquisition cycle only after it completed successfully.
+
+ Args:
+ acquisition: Algorithm acquisition mode.
+ completed: Whether generation completed or the finite loader was exhausted.
+
+ Returns:
+ New progress with exactly one acquisition counter advanced.
+ """
+ _require_acquisition(acquisition)
+ if type(completed) is not bool:
+ raise TypeError(
+ f"expected completed to be bool, received "
+ f"{type(completed).__name__}: {completed!r}"
+ )
+ if not completed:
+ raise RuntimeError(
+ f"cannot advance {acquisition.value!r} because its acquisition cycle "
+ "did not complete"
+ )
+ if acquisition is AcquisitionMode.GENERATION:
+ return replace(self, rollout_iteration=self.rollout_iteration + 1)
+ return replace(self, data_epoch=self.data_epoch + 1)
+
+ def advance_optimizer_step(self, count: int = 1) -> "TrainingProgress":
+ """Advance optimizer progress independently of acquisition cycles.
+
+ Args:
+ count: Positive number of completed optimizer updates.
+
+ Returns:
+ New progress with ``optimizer_step`` advanced by ``count``.
+ """
+ _require_positive_int(count, "count")
+ return replace(self, optimizer_step=self.optimizer_step + count)
+
+
+class AcquisitionHost(Protocol):
+ """Define trainer hooks consumed by acquisition drivers."""
+
+ dataloader: DataLoader
+
+ def set_trajectory_seed(self, seed: int) -> None:
+ """Set the seed for the next generated acquisition."""
+ ...
+
+ def run_generation_acquisition(self) -> None:
+ """Generate examples and execute their algorithm-specific update."""
+ ...
+
+ def train_on_dataset_batch(self, batch: Any) -> None:
+ """Execute the declared stages for one acquired dataset batch."""
+ ...
+
+
+class AcquisitionDriver(Protocol):
+ """Define the runtime interface shared by acquisition strategies."""
+
+ def prepare_cycle(
+ self,
+ host: AcquisitionHost,
+ progress: TrainingProgress,
+ *,
+ seed: int,
+ ) -> None:
+ """Prepare one acquisition cycle before periodic boundaries."""
+ ...
+
+ def run_cycle(
+ self,
+ host: AcquisitionHost,
+ progress: TrainingProgress,
+ ) -> None:
+ """Run one acquisition cycle without mutating progress."""
+ ...
+
+
+class GenerationAcquisitionDriver:
+ """Acquire one generated example collection and train on it."""
+
+ def prepare_cycle(
+ self,
+ host: AcquisitionHost,
+ progress: TrainingProgress,
+ *,
+ seed: int,
+ ) -> None:
+ """Seed one generation cycle from its completed-iteration count.
+
+ Args:
+ host: Trainer hooks consumed by this driver.
+ progress: Immutable progress at cycle start.
+ seed: Base training seed.
+ """
+ _require_progress(progress)
+ _require_int(seed, "seed")
+ host.set_trajectory_seed(seed + progress.rollout_iteration)
+
+ def run_cycle(
+ self,
+ host: AcquisitionHost,
+ progress: TrainingProgress,
+ ) -> None:
+ """Generate and consume one acquisition without advancing progress.
+
+ Args:
+ host: Trainer hooks consumed by this driver.
+ progress: Immutable progress at cycle start.
+ """
+ _require_progress(progress)
+ host.run_generation_acquisition()
+
+
+class DatasetAcquisitionDriver:
+ """Acquire every batch in one finite distributed dataset epoch."""
+
+ def prepare_cycle(
+ self,
+ host: AcquisitionHost,
+ progress: TrainingProgress,
+ *,
+ seed: int,
+ ) -> None:
+ """Validate a finite official distributed loader before side effects.
+
+ Args:
+ host: Trainer whose dataloader supplies offline examples.
+ progress: Immutable progress at cycle start.
+ seed: Common interface value; dataset shuffling uses sampler epochs.
+ """
+ _require_progress(progress)
+ _require_int(seed, "seed")
+ _require_distributed_sampler(host)
+
+ def run_cycle(
+ self,
+ host: AcquisitionHost,
+ progress: TrainingProgress,
+ ) -> None:
+ """Exhaust exactly one dataloader traversal without advancing progress.
+
+ Args:
+ host: Trainer whose dataloader supplies offline examples.
+ progress: Immutable progress at cycle start.
+
+ Note:
+ Batch or optimization exceptions propagate. The caller advances
+ ``data_epoch`` only after this method returns normally.
+ """
+ _require_progress(progress)
+ sampler = _require_distributed_sampler(host)
+ sampler.set_epoch(progress.data_epoch)
+ for batch in host.dataloader:
+ host.train_on_dataset_batch(batch)
+
+
+def build_acquisition_driver(contract: ExecutionContract) -> AcquisitionDriver:
+ """Build the acquisition strategy declared by an execution contract.
+
+ Args:
+ contract: Typed algorithm execution contract.
+
+ Returns:
+ Generation or dataset acquisition driver.
+ """
+ if not isinstance(contract, ExecutionContract):
+ raise TypeError(
+ "expected contract to be ExecutionContract, received "
+ f"{type(contract).__name__}: {contract!r}"
+ )
+ if contract.acquisition is AcquisitionMode.GENERATION:
+ return GenerationAcquisitionDriver()
+ return DatasetAcquisitionDriver()
+
+
+def _require_distributed_sampler(host: AcquisitionHost) -> DistributedSampler:
+ """Return the official sampler required by dataset acquisition."""
+ dataloader = getattr(host, "dataloader", None)
+ sampler = getattr(dataloader, "sampler", None)
+ if not isinstance(sampler, DistributedSampler):
+ sampler_name = type(sampler).__name__ if sampler is not None else "None"
+ raise TypeError(
+ "expected offline dataloader.sampler to be "
+ "torch.utils.data.DistributedSampler, received "
+ f"{sampler_name}; use DistributedSampler even when num_replicas=1"
+ )
+ return sampler
+
+
+def _require_acquisition(value: object) -> None:
+ """Require a typed acquisition mode."""
+ if not isinstance(value, AcquisitionMode):
+ raise TypeError(
+ "expected acquisition to be AcquisitionMode, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _require_non_negative_int(value: object, field_name: str) -> None:
+ """Require a non-negative integer counter without accepting booleans."""
+ if type(value) is not int:
+ raise TypeError(
+ f"expected {field_name} to be int, received " f"{type(value).__name__}: {value!r}"
+ )
+ if value < 0:
+ raise ValueError(f"expected {field_name} >= 0, received {value}")
+
+
+def _require_positive_int(value: object, field_name: str) -> None:
+ """Require a positive integer without accepting booleans."""
+ if type(value) is not int:
+ raise TypeError(
+ f"expected {field_name} to be int, received " f"{type(value).__name__}: {value!r}"
+ )
+ if value < 1:
+ raise ValueError(f"expected {field_name} >= 1, received {value}")
+
+
+def _require_int(value: object, field_name: str) -> None:
+ """Require an integer without accepting booleans."""
+ if type(value) is not int:
+ raise TypeError(
+ f"expected {field_name} to be int, received " f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _require_progress(progress: object) -> None:
+ """Require immutable typed progress at a driver boundary."""
+ if not isinstance(progress, TrainingProgress):
+ raise TypeError(
+ "expected progress to be TrainingProgress, received "
+ f"{type(progress).__name__}: {progress!r}"
+ )
+
+
+__all__ = [
+ "AcquisitionDriver",
+ "AcquisitionHost",
+ "AcquisitionMode",
+ "DatasetAcquisitionDriver",
+ "ExecutionContract",
+ "FeedbackMode",
+ "GenerationAcquisitionDriver",
+ "OFFLINE_EXECUTION_CONTRACT",
+ "ONLINE_EXECUTION_CONTRACT",
+ "ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT",
+ "TrainingProgress",
+ "build_acquisition_driver",
+]
diff --git a/src/flow_factory/trainers/loader.py b/src/flow_factory/trainers/loader.py
index f75036eaf..4554f342a 100644
--- a/src/flow_factory/trainers/loader.py
+++ b/src/flow_factory/trainers/loader.py
@@ -24,10 +24,11 @@
from accelerate import Accelerator, DistributedDataParallelKwargs
from accelerate.utils import ProjectConfiguration, set_seed
+from ..contracts.execution import ExecutionContract
from ..hparams import Arguments, get_training_args_class
+from ..loading.backend import configure_backend_loading
from ..models.loader import load_model
from ..models.registry import get_model_adapter_class
-from ..loading.backend import configure_backend_loading
from ..utils.env_utils import reconcile_config
from ..utils.logger_utils import setup_logger
from .abc import BaseTrainer, validate_supported_distributed_plan
@@ -78,6 +79,30 @@ def load_trainer(config: Arguments) -> BaseTrainer:
config.training_args.trainer_type = "my_package.trainers.PPOTrainer"
trainer = load_trainer(config)
"""
+ # Resolve and validate algorithm semantics before constructing an Accelerator or
+ # loading model weights. A stale trainer/argument registry pairing must fail with
+ # no external allocation side effects.
+ trainer_type = config.training_args.trainer_type
+ try:
+ trainer_cls = get_trainer_class(trainer_type)
+ except ImportError as e:
+ registered_trainers = list(list_registered_trainers().keys())
+ raise ImportError(
+ f"Failed to load trainer '{trainer_type}'. "
+ f"Available trainers: {registered_trainers}"
+ ) from e
+ if not isinstance(trainer_cls, type):
+ raise TypeError(
+ f"trainer {trainer_type!r} must resolve to a class, " f"received {trainer_cls!r}"
+ )
+ uses_execution_kernel = issubclass(trainer_cls, BaseTrainer)
+ has_typed_training_contract = isinstance(
+ getattr(type(config.training_args), "execution_contract", None),
+ ExecutionContract,
+ )
+ if uses_execution_kernel and has_typed_training_contract:
+ trainer_cls.validate_training_arguments_contract(config.training_args)
+
# Resolve DDP find_unused_parameters from the adapter class (opt-in per
# model). Resolving via the registry imports only the class (no
# instantiation). This kwarg only affects the DDP backend; FSDP/DeepSpeed
@@ -85,6 +110,8 @@ def load_trainer(config: Arguments) -> BaseTrainer:
# all-reduce with backward; adapters that leave trainable params ungraded in
# some iterations (e.g. Qwen-Image) opt in via ddp_find_unused_parameters.
adapter_cls = get_model_adapter_class(config.model_args.model_type)
+ if uses_execution_kernel:
+ trainer_cls.validate_adapter_class_execution_contract(adapter_cls)
find_unused = _requires_ddp_unused_parameter_detection(config, adapter_cls)
ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=find_unused)
@@ -118,18 +145,6 @@ def load_trainer(config: Arguments) -> BaseTrainer:
# Initialize model adapter
adapter = load_model(config=config, accelerator=accelerator)
- # Get trainer class from registry
- trainer_type = config.training_args.trainer_type
-
- try:
- trainer_cls = get_trainer_class(trainer_type)
- except ImportError as e:
- registered_trainers = list(list_registered_trainers().keys())
- raise ImportError(
- f"Failed to load trainer '{trainer_type}'. "
- f"Available trainers: {registered_trainers}"
- ) from e
-
return trainer_cls(
config=config,
accelerator=accelerator,
diff --git a/tests/hparams/test_execution_contracts.py b/tests/hparams/test_execution_contracts.py
new file mode 100644
index 000000000..a6af46567
--- /dev/null
+++ b/tests/hparams/test_execution_contracts.py
@@ -0,0 +1,76 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for algorithm-owned execution contracts in training arguments."""
+
+from dataclasses import fields
+
+import pytest
+
+from flow_factory.contracts.execution import (
+ ONLINE_EXECUTION_CONTRACT,
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+)
+from flow_factory.hparams.training_args import (
+ DiffusionOPDTrainingArguments,
+ DMD2TrainingArguments,
+ TDMR1TrainingArguments,
+ TDMTrainingArguments,
+ TrainingArguments,
+ list_registered_training_args,
+)
+from flow_factory.trainers.registry import get_trainer_class, list_registered_trainers
+
+
+def test_builtin_argument_and_trainer_registries_do_not_drift() -> None:
+ """Every built-in algorithm pair declares equal execution semantics."""
+ argument_registry = list_registered_training_args()
+ trainer_registry = list_registered_trainers()
+
+ assert set(argument_registry) == set(trainer_registry)
+ for name, arguments_class in argument_registry.items():
+ assert arguments_class.execution_contract == get_trainer_class(name).execution_contract
+
+
+def test_reward_free_distillation_declares_feedback_independently() -> None:
+ """Distillation remains generation acquisition while omitting runtime rewards."""
+ for arguments_class in (
+ DMD2TrainingArguments,
+ TDMTrainingArguments,
+ DiffusionOPDTrainingArguments,
+ ):
+ assert arguments_class.execution_contract is ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
+
+ assert TDMR1TrainingArguments.execution_contract is ONLINE_EXECUTION_CONTRACT
+
+
+def test_execution_contract_is_not_a_serialized_configuration_field() -> None:
+ """Users select semantics through trainer_type rather than raw contract fields."""
+ arguments = TrainingArguments()
+
+ assert "execution_contract" not in {field.name for field in fields(arguments)}
+ assert "execution_contract" not in arguments.to_dict()
+
+
+@pytest.mark.parametrize(
+ "values",
+ [
+ {"execution_contract": "dataset"},
+ {"extra_kwargs": {"execution_contract": "dataset"}},
+ ],
+)
+def test_user_cannot_override_execution_contract(values: dict) -> None:
+ """The selected algorithm owns its execution semantics."""
+ with pytest.raises(ValueError, match="selected by trainer_type"):
+ TrainingArguments.from_dict(values)
diff --git a/tests/trainers/test_execution_kernel.py b/tests/trainers/test_execution_kernel.py
new file mode 100644
index 000000000..808e76002
--- /dev/null
+++ b/tests/trainers/test_execution_kernel.py
@@ -0,0 +1,255 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Focused tests for generated and dataset acquisition execution."""
+
+from types import SimpleNamespace
+from typing import Any, List, Optional
+from unittest.mock import patch
+
+import pytest
+import torch
+from torch.utils.data import DataLoader, DistributedSampler
+
+from flow_factory.contracts.execution import (
+ OFFLINE_EXECUTION_CONTRACT,
+ ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT,
+ AcquisitionMode,
+)
+from flow_factory.hparams.training_args import TrainingArguments
+from flow_factory.trainers.abc import BaseTrainer
+from flow_factory.trainers.execution import (
+ DatasetAcquisitionDriver,
+ GenerationAcquisitionDriver,
+ TrainingProgress,
+ build_acquisition_driver,
+)
+from flow_factory.trainers.loader import load_trainer
+
+
+class _RecordingDistributedSampler(DistributedSampler):
+ """Record sampler epoch updates while retaining official behavior."""
+
+ def __init__(self, dataset: List[int], events: List[str]) -> None:
+ super().__init__(dataset, num_replicas=1, rank=0, shuffle=False)
+ self._events = events
+
+ def set_epoch(self, epoch: int) -> None:
+ """Record and apply one complete data-epoch index.
+
+ Args:
+ epoch: Completed data epochs before the next traversal.
+ """
+ self._events.append(f"set_epoch:{epoch}")
+ super().set_epoch(epoch)
+
+
+class _GenerationTrainer(BaseTrainer):
+ """Exercise the shared generated-acquisition cadence."""
+
+ def __init__(self, cycles: int) -> None:
+ self.events: List[str] = []
+ self.progress = TrainingProgress()
+ self._cycles = cycles
+ self.adapter = SimpleNamespace(
+ set_trajectory_seed=lambda seed: self.events.append(f"seed:{seed}"),
+ ema_step=lambda step: self.events.append(f"ema:{step}"),
+ )
+ self.training_args = SimpleNamespace(seed=10)
+ self.log_args = SimpleNamespace(save_freq=0, save_dir=None, run_name="run")
+ self.eval_args = SimpleNamespace(eval_freq=0)
+
+ def should_continue_training(self) -> bool:
+ """Stop after the requested generated acquisitions."""
+ return self.epoch < self._cycles
+
+ def sample(self) -> List[Any]:
+ """Record generated acquisition."""
+ self.events.append("sample")
+ return []
+
+ def prepare_feedback(self, samples: List[Any]) -> None:
+ """Record the runtime reward stage."""
+ del samples
+ self.events.append("feedback")
+
+ def optimize(self, samples: List[Any]) -> None:
+ """Record generated-example optimization."""
+ del samples
+ self.events.append("optimize")
+
+
+class _RewardFreeGenerationTrainer(_GenerationTrainer):
+ """Generated acquisition whose algorithm declares no runtime feedback."""
+
+ execution_contract = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
+
+
+class _DatasetTrainer(BaseTrainer):
+ """Exercise finite dataset acquisition without calling sample()."""
+
+ execution_contract = OFFLINE_EXECUTION_CONTRACT
+
+ def __init__(self, cycles: int, *, fail_on_value: Optional[int] = None) -> None:
+ self.events: List[str] = []
+ self.progress = TrainingProgress()
+ self._cycles = cycles
+ self._fail_on_value = fail_on_value
+ dataset = list(range(5))
+ sampler = _RecordingDistributedSampler(dataset, self.events)
+ self.dataloader = DataLoader(dataset, batch_size=2, sampler=sampler)
+ self.adapter = SimpleNamespace(
+ set_trajectory_seed=lambda seed: self.events.append(f"unexpected_seed:{seed}"),
+ ema_step=lambda step: self.events.append(f"ema:{step}"),
+ )
+ self.training_args = SimpleNamespace(seed=10)
+ self.log_args = SimpleNamespace(save_freq=0, save_dir=None, run_name="run")
+ self.eval_args = SimpleNamespace(eval_freq=0)
+
+ def should_continue_training(self) -> bool:
+ """Stop after the requested complete data epochs."""
+ return self.epoch < self._cycles
+
+ def sample(self) -> List[Any]:
+ """Reject the online API on the dataset path."""
+ raise AssertionError("dataset acquisition must not call sample()")
+
+ def optimize_batch(self, batch: torch.Tensor) -> None:
+ """Record one dataset batch and advance its independent optimizer count.
+
+ Args:
+ batch: Batch yielded by the finite official loader.
+ """
+ values = batch.tolist()
+ self.events.append(f"batch:{values}")
+ if self._fail_on_value is not None and values[0] == self._fail_on_value:
+ raise RuntimeError("dataset update failed")
+ self.step += 1
+
+
+def test_progress_tracks_optimizer_and_acquisition_counters_independently() -> None:
+ """Several optimizer updates may occur inside one complete data epoch."""
+ progress = TrainingProgress(optimizer_step=3, rollout_iteration=2, data_epoch=1)
+
+ assert progress.cycle_index(AcquisitionMode.GENERATION) == 2
+ assert progress.cycle_index(AcquisitionMode.DATASET) == 1
+ assert progress.advance_optimizer_step(2).optimizer_step == 5
+ assert progress.advance_acquisition(
+ AcquisitionMode.DATASET, completed=True
+ ) == TrainingProgress(optimizer_step=3, rollout_iteration=2, data_epoch=2)
+
+
+def test_generated_acquisition_preserves_existing_online_cadence() -> None:
+ """Generation still seeds, samples, receives feedback, optimizes, and steps EMA."""
+ trainer = _GenerationTrainer(cycles=2)
+
+ trainer.start()
+
+ assert trainer.events == [
+ "seed:10",
+ "sample",
+ "feedback",
+ "optimize",
+ "ema:0",
+ "seed:11",
+ "sample",
+ "feedback",
+ "optimize",
+ "ema:1",
+ ]
+ assert trainer.progress == TrainingProgress(rollout_iteration=2)
+
+
+def test_generated_acquisition_may_omit_runtime_feedback() -> None:
+ """Reward-free distillation is generated acquisition without a fake feedback stage."""
+ trainer = _RewardFreeGenerationTrainer(cycles=1)
+
+ trainer.start()
+
+ assert trainer.events == ["seed:10", "sample", "optimize", "ema:0"]
+
+
+def test_dataset_epoch_is_exactly_one_complete_dataloader_traversal() -> None:
+ """Dataset acquisition exhausts the official loader before advancing its epoch."""
+ trainer = _DatasetTrainer(cycles=2)
+
+ trainer.start()
+
+ assert trainer.events == [
+ "set_epoch:0",
+ "batch:[0, 1]",
+ "batch:[2, 3]",
+ "batch:[4]",
+ "set_epoch:1",
+ "batch:[0, 1]",
+ "batch:[2, 3]",
+ "batch:[4]",
+ ]
+ assert trainer.progress == TrainingProgress(optimizer_step=6, data_epoch=2)
+
+
+def test_failed_dataset_batch_does_not_publish_a_partial_epoch() -> None:
+ """A partial loader traversal never increments data_epoch."""
+ trainer = _DatasetTrainer(cycles=1, fail_on_value=2)
+
+ with pytest.raises(RuntimeError, match="dataset update failed"):
+ trainer.start()
+
+ assert trainer.events == ["set_epoch:0", "batch:[0, 1]", "batch:[2, 3]"]
+ assert trainer.progress == TrainingProgress(optimizer_step=1)
+
+
+def test_dataset_driver_requires_official_distributed_sampler_on_one_process() -> None:
+ """Single-process offline execution uses the same distribution contract."""
+ trainer = _DatasetTrainer(cycles=1)
+ trainer.dataloader = DataLoader(list(range(4)), batch_size=2)
+
+ with pytest.raises(TypeError, match="DistributedSampler even when num_replicas=1"):
+ trainer.start()
+
+ assert trainer.events == []
+
+
+def test_driver_selection_depends_only_on_acquisition() -> None:
+ """Feedback choices do not duplicate loader or cycle selection."""
+ assert isinstance(
+ build_acquisition_driver(_GenerationTrainer.execution_contract),
+ GenerationAcquisitionDriver,
+ )
+ assert isinstance(
+ build_acquisition_driver(OFFLINE_EXECUTION_CONTRACT),
+ DatasetAcquisitionDriver,
+ )
+
+
+def test_loader_rejects_contract_drift_before_adapter_or_accelerator_loading() -> None:
+ """A stale registry pair fails before any heavyweight runtime side effect."""
+
+ class _MismatchedTrainer(BaseTrainer):
+ execution_contract = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
+
+ config = SimpleNamespace(training_args=TrainingArguments())
+ with (
+ patch(
+ "flow_factory.trainers.loader.get_trainer_class",
+ return_value=_MismatchedTrainer,
+ ),
+ patch("flow_factory.trainers.loader.get_model_adapter_class") as adapter_class,
+ patch("flow_factory.trainers.loader.Accelerator") as accelerator,
+ pytest.raises(ValueError, match="execution contract mismatch"),
+ ):
+ load_trainer(config)
+
+ adapter_class.assert_not_called()
+ accelerator.assert_not_called()
From afb0068fbda65c598919027564f3c330d752a261 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:14:45 +0800
Subject: [PATCH 07/76] feat(data): add offline supervision data plane
---
src/flow_factory/data_utils/dataset.py | 223 ++++-
src/flow_factory/data_utils/loader.py | 1 +
.../data_utils/offline_condition_cache.py | 266 ++++++
.../data_utils/offline_dataset.py | 770 +++++++++++++++
src/flow_factory/data_utils/offline_loader.py | 239 +++++
.../data_utils/offline_train_data.py | 470 ++++++++++
src/flow_factory/data_utils/schema.py | 289 ++++++
.../test_offline_condition_cache.py | 409 ++++++++
tests/data_utils/test_offline_dataset.py | 880 ++++++++++++++++++
tests/data_utils/test_offline_loader.py | 502 ++++++++++
tests/data_utils/test_offline_train_data.py | 653 +++++++++++++
tests/data_utils/test_schema.py | 304 ++++++
12 files changed, 4973 insertions(+), 33 deletions(-)
create mode 100644 src/flow_factory/data_utils/offline_condition_cache.py
create mode 100644 src/flow_factory/data_utils/offline_dataset.py
create mode 100644 src/flow_factory/data_utils/offline_loader.py
create mode 100644 src/flow_factory/data_utils/offline_train_data.py
create mode 100644 src/flow_factory/data_utils/schema.py
create mode 100644 tests/data_utils/test_offline_condition_cache.py
create mode 100644 tests/data_utils/test_offline_dataset.py
create mode 100644 tests/data_utils/test_offline_loader.py
create mode 100644 tests/data_utils/test_offline_train_data.py
create mode 100644 tests/data_utils/test_schema.py
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index 409aa93c5..e7c0f0ae4 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -21,7 +21,7 @@
import os
import shutil
from dataclasses import asdict
-from typing import Any, Callable, Dict, List, Optional, Protocol, Union
+from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Sequence, Union
import imageio.v3 as iio
import numpy as np
@@ -34,7 +34,7 @@
from PIL import Image
from torch.utils.data import Dataset
-from ..samples.references import canonicalize_reference_manifest
+from ..samples.references import canonicalize_reference_manifest, parse_reference_manifest
from ..utils.audio import load_audio
from ..utils.base import (
filter_kwargs,
@@ -149,6 +149,9 @@ def __init__(
video_dir: Optional[str] = None,
audio_dir: Optional[str] = None,
target_arrow_path: Optional[str] = None,
+ raw_dataset: Optional[HFDataset] = None,
+ source_hash_override: Optional[str] = None,
+ passthrough_columns: Optional[Sequence[str]] = None,
**kwargs,
):
"""
@@ -183,6 +186,17 @@ def __init__(
the main rank can metadata-merge them without re-serialization.
When ``None``, HF falls back to its default cache path under
``~/.cache/huggingface/datasets`` (single-process / legacy).
+ raw_dataset: Optional in-memory HuggingFace dataset. When provided,
+ file discovery is skipped and ``source_hash_override`` is required.
+ This is intended for narrow projections such as offline input-only
+ condition caches, not for passing a complete source manifest.
+ source_hash_override: Stable source-content identity used in place of
+ hashing ``{dataset_dir}/{split}.jsonl`` or ``.txt``. Required with
+ ``raw_dataset`` so unrelated in-memory datasets cannot share a cache.
+ passthrough_columns: Raw columns that must survive preprocessing at the
+ top level unchanged. They are never forwarded to ``preprocess_func``
+ or copied into ``metadata``. A preprocess result using one of these
+ names is rejected as a collision.
**kwargs: Additional arguments (ignored)
Note:
@@ -203,37 +217,58 @@ def __init__(
self.video_dir = video_dir
self.audio_dir = audio_dir
self._uses_ordered_references = _supports_ordered_references(preprocess_func)
+ self._source_hash_override = _validate_source_hash_override(source_hash_override)
+ self._passthrough_columns = _normalize_passthrough_columns(passthrough_columns)
if self.shard_index is not None and self.shard_index > 0:
disable_progress_bar()
- raw_dataset = self._load_raw_dataset()
+ if raw_dataset is None:
+ loaded_raw_dataset = self._load_raw_dataset()
+ else:
+ if not isinstance(raw_dataset, HFDataset):
+ raise TypeError(
+ "raw_dataset must be a datasets.Dataset, " f"got {type(raw_dataset).__name__}"
+ )
+ if self._source_hash_override is None:
+ raise ValueError("source_hash_override is required when raw_dataset is provided")
+ loaded_raw_dataset = raw_dataset
+
+ missing_passthrough_columns = set(self._passthrough_columns) - set(
+ loaded_raw_dataset.column_names
+ )
+ if missing_passthrough_columns:
+ raise ValueError(
+ "passthrough columns are missing from the raw dataset: "
+ f"{sorted(missing_passthrough_columns)!r}"
+ )
- if max_dataset_size is not None and len(raw_dataset) > max_dataset_size:
- raw_dataset = raw_dataset.select(range(max_dataset_size))
+ if max_dataset_size is not None and len(loaded_raw_dataset) > max_dataset_size:
+ loaded_raw_dataset = loaded_raw_dataset.select(range(max_dataset_size))
logger.info(f"Dataset size limited to {max_dataset_size} samples.")
self._ordered_reference_source_hash = ""
if self._uses_ordered_references:
- if "references" not in raw_dataset.column_names:
+ if "references" not in loaded_raw_dataset.column_names:
raise ValueError(
"ordered-reference preprocessing requires a references column, "
- f"got columns={raw_dataset.column_names!r} in {self.data_root!r}"
+ f"got columns={loaded_raw_dataset.column_names!r} in {self.data_root!r}"
)
canonical_manifests = [
- canonicalize_reference_manifest(references, row_index=row_index)
- for row_index, references in enumerate(raw_dataset["references"])
+ _canonicalize_ordered_reference_value(references, row_index=row_index)
+ for row_index, references in enumerate(loaded_raw_dataset["references"])
]
self._ordered_reference_source_hash = hashlib.sha256(
"\n".join(canonical_manifests).encode("utf-8")
).hexdigest()
- extra_hash_strs = list(extra_hash_strs or []) + [
- self._ordered_reference_source_hash
- ]
+ if self._source_hash_override is None:
+ extra_hash_strs = list(extra_hash_strs or []) + [
+ self._ordered_reference_source_hash
+ ]
if enable_preprocess:
self.processed_dataset = self._preprocess_dataset(
- raw_dataset=raw_dataset,
+ raw_dataset=loaded_raw_dataset,
preprocess_func=preprocess_func,
preprocess_kwargs=preprocess_kwargs or {},
preprocessing_batch_size=preprocessing_batch_size,
@@ -241,9 +276,10 @@ def __init__(
max_dataset_size=max_dataset_size,
extra_hash_strs=extra_hash_strs,
target_arrow_path=target_arrow_path,
+ source_hash_override=self._source_hash_override,
)
else:
- self.processed_dataset = raw_dataset
+ self.processed_dataset = loaded_raw_dataset
self.merged_cache_path = None
def _load_raw_dataset(self) -> HFDataset:
@@ -285,6 +321,7 @@ def _preprocess_dataset(
max_dataset_size: Optional[int],
extra_hash_strs: Optional[List[str]] = None,
target_arrow_path: Optional[str] = None,
+ source_hash_override: Optional[str] = None,
) -> HFDataset:
"""Apply preprocessing to raw dataset with caching.
@@ -308,6 +345,7 @@ def _preprocess_dataset(
preprocess_func=preprocess_func,
preprocess_kwargs=preprocess_kwargs,
extra_hash_strs=extra_hash_strs,
+ source_hash_override=source_hash_override,
)
if self.num_shards and self.num_shards > 1:
@@ -333,7 +371,7 @@ def _preprocess_dataset(
os.makedirs(self.cache_dir, exist_ok=True)
if target_arrow_path is not None:
- os.makedirs(os.path.dirname(target_arrow_path), exist_ok=True)
+ os.makedirs(os.path.dirname(os.path.abspath(target_arrow_path)), exist_ok=True)
processed_dataset = raw_dataset.map(
self._preprocess_batch,
@@ -432,6 +470,7 @@ def _preprocess_batch(
# The columns that are used in preprocess and maintained in the final results.
PREPROCESS_COLUMNS = ("prompt", "negative_prompt", "images", "videos", "audios")
metadata_excluded_columns = set(PREPROCESS_COLUMNS)
+ metadata_excluded_columns.update(self._passthrough_columns)
if self._uses_ordered_references:
metadata_excluded_columns.update({"references", "reference_manifest"})
@@ -492,8 +531,7 @@ def _preprocess_batch(
video_paths = [video_paths]
videos = [
- load_video_frames(_resolve_path(video_dir, video_path))
- for video_path in video_paths
+ _load_grouped_video(video_dir, video_spec) for video_spec in video_paths
]
video_pts = [pil_image_to_tensor(video) for video in videos]
video_args["videos"].append(videos)
@@ -519,8 +557,7 @@ def _preprocess_batch(
if isinstance(audio_paths, str):
audio_paths = [audio_paths]
audios = [
- load_audio(_resolve_path(audio_dir, audio_path))
- for audio_path in audio_paths
+ _load_grouped_audio(audio_dir, audio_spec) for audio_spec in audio_paths
]
# Always store as List[Tensor] (no single-audio unwrap) so
# downstream encode_audio sees a uniform type within the batch.
@@ -534,7 +571,10 @@ def _preprocess_batch(
canonical_manifests = []
for row_offset, references in enumerate(raw_references):
row_index = indices[row_offset]
- manifest = canonicalize_reference_manifest(references, row_index=row_index)
+ manifest = _canonicalize_ordered_reference_value(
+ references,
+ row_index=row_index,
+ )
canonical_manifests.append(manifest)
loaded_reference_batch.append(
[
@@ -561,6 +601,12 @@ def _preprocess_batch(
}
filtered_args = filter_kwargs(self._preprocess_func, **input_args)
preprocess_res = self._preprocess_func(**filtered_args)
+ passthrough_collisions = set(preprocess_res) & set(self._passthrough_columns)
+ if passthrough_collisions:
+ raise ValueError(
+ "preprocess result collides with passthrough columns: "
+ f"{sorted(passthrough_collisions)!r}"
+ )
# 6. Process results - move tensors to CPU for caching.
# Image-valued adapter outputs (declared via `python_format_columns`)
@@ -646,6 +692,7 @@ def compute_cache_path(
preprocess_func: Optional[Callable],
preprocess_kwargs: Optional[Dict[str, Any]],
extra_hash_strs: Optional[List[str]] = None,
+ source_hash_override: Optional[str] = None,
digits: int = 32,
) -> str:
"""Compute merged cache path by hashing all components.
@@ -670,19 +717,26 @@ def compute_cache_path(
"""
dataset_root = os.path.abspath(os.path.expanduser(dataset_dir))
dataset_name = os.path.basename(dataset_root)
- source_candidates = (
- os.path.join(dataset_root, f"{split}.jsonl"),
- os.path.join(dataset_root, f"{split}.txt"),
- )
- source_path = next((path for path in source_candidates if os.path.isfile(path)), None)
- if source_path is None:
- source_hash = "missing"
+ validated_source_hash_override = _validate_source_hash_override(source_hash_override)
+ if validated_source_hash_override is not None:
+ source_hash = validated_source_hash_override
else:
- hasher = hashlib.sha256()
- with open(source_path, "rb") as source_file:
- for chunk in iter(lambda: source_file.read(1024 * 1024), b""):
- hasher.update(chunk)
- source_hash = hasher.hexdigest()
+ source_candidates = (
+ os.path.join(dataset_root, f"{split}.jsonl"),
+ os.path.join(dataset_root, f"{split}.txt"),
+ )
+ source_path = next(
+ (path for path in source_candidates if os.path.isfile(path)),
+ None,
+ )
+ if source_path is None:
+ source_hash = "missing"
+ else:
+ hasher = hashlib.sha256()
+ with open(source_path, "rb") as source_file:
+ for chunk in iter(lambda: source_file.read(1024 * 1024), b""):
+ hasher.update(chunk)
+ source_hash = hasher.hexdigest()
cutoff_str = str(max_dataset_size) if max_dataset_size else "full"
funcs_hash = _compute_encode_funcs_hash(preprocess_func, digits=16)
hashable_kwargs = _select_cache_relevant_kwargs(preprocess_func, preprocess_kwargs)
@@ -921,6 +975,41 @@ def collate_fn(batch: List[Dict[str, Any]]) -> Dict[str, Any]:
# ========================================================================================
+def _validate_source_hash_override(value: Optional[str]) -> Optional[str]:
+ """Validate an optional caller-owned source-content identity."""
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ raise TypeError(
+ "source_hash_override must be a non-empty string or None, "
+ f"got {type(value).__name__}"
+ )
+ if not value.strip():
+ raise ValueError("source_hash_override must be a non-empty string")
+ return value
+
+
+def _normalize_passthrough_columns(columns: Optional[Sequence[str]]) -> tuple[str, ...]:
+ """Return validated passthrough column names in caller-declared order."""
+ if columns is None:
+ return ()
+ if isinstance(columns, (str, bytes)):
+ raise TypeError("passthrough_columns must be a sequence of column names, not a string")
+ normalized = tuple(columns)
+ for column in normalized:
+ if not isinstance(column, str) or not column:
+ raise ValueError(
+ "passthrough column names must be non-empty strings, " f"got {column!r}"
+ )
+ if len(set(normalized)) != len(normalized):
+ raise ValueError(f"passthrough_columns contains duplicates: {normalized!r}")
+ if METADATA_COLUMN in normalized:
+ raise ValueError(
+ f"{METADATA_COLUMN!r} is owned by GeneralDataset and cannot be a " "passthrough column"
+ )
+ return normalized
+
+
def _supports_ordered_references(preprocess_func: Optional[Callable]) -> bool:
"""Return whether a bound preprocessor explicitly opts into ordered references."""
if preprocess_func is None:
@@ -932,6 +1021,74 @@ def _supports_ordered_references(preprocess_func: Optional[Callable]) -> bool:
)
+def _canonicalize_ordered_reference_value(value: Any, row_index: int) -> str:
+ """Canonicalize either a legacy reference list or an opaque Arrow string."""
+ if isinstance(value, str):
+ references = parse_reference_manifest(value, row_index=row_index)
+ else:
+ references = value
+ return canonicalize_reference_manifest(references, row_index=row_index)
+
+
+def _load_grouped_video(base_dir: str, spec: Any) -> List[Image.Image]:
+ """Decode one grouped video path, honoring an optional FPS override."""
+ path, fps = _parse_grouped_media_spec(
+ spec,
+ media_type="video",
+ rate_name="fps",
+ )
+ return load_video_frames(_resolve_path(base_dir, path), fps=fps)
+
+
+def _load_grouped_audio(base_dir: str, spec: Any) -> torch.Tensor:
+ """Decode one grouped audio path, honoring an optional sample-rate override."""
+ path, sample_rate = _parse_grouped_media_spec(
+ spec,
+ media_type="audio",
+ rate_name="sample_rate",
+ )
+ if sample_rate is not None and not isinstance(sample_rate, int):
+ raise TypeError(
+ "grouped audio entry requires an integer sample_rate, " f"got {sample_rate!r}"
+ )
+ return load_audio(_resolve_path(base_dir, path), sample_rate=sample_rate)
+
+
+def _parse_grouped_media_spec(
+ spec: Any,
+ *,
+ media_type: str,
+ rate_name: str,
+) -> tuple[str, Optional[Union[int, float]]]:
+ """Normalize a legacy path string or a V2 projected path/rate mapping."""
+ if isinstance(spec, str):
+ return spec, None
+ if not isinstance(spec, Mapping):
+ raise TypeError(
+ f"expected grouped {media_type} entry to be a path string or mapping, "
+ f"got {type(spec).__name__}: {spec!r}"
+ )
+ unknown_keys = set(spec) - {"path", rate_name}
+ if unknown_keys:
+ raise ValueError(f"grouped {media_type} entry has unknown keys: {sorted(unknown_keys)!r}")
+ path = spec.get("path")
+ if not isinstance(path, str) or not path:
+ raise ValueError(
+ f"grouped {media_type} entry requires a non-empty path string, got {path!r}"
+ )
+ rate = spec.get(rate_name)
+ if rate is not None and (
+ isinstance(rate, bool)
+ or not isinstance(rate, (int, float))
+ or not math.isfinite(rate)
+ or rate <= 0
+ ):
+ raise ValueError(
+ f"grouped {media_type} entry requires finite positive {rate_name}, got {rate!r}"
+ )
+ return path, rate
+
+
def _load_ordered_reference(
entry: Dict[str, Any],
data_root: str,
@@ -1220,7 +1377,7 @@ def _resolve_path(base_dir: str, path: str) -> str:
return path if os.path.isabs(path) else os.path.join(base_dir, path)
-def load_video_frames(video_path: str, fps: Optional[int] = None) -> List[Image.Image]:
+def load_video_frames(video_path: str, fps: Optional[float] = None) -> List[Image.Image]:
"""
Load video frames using imageio (diffusers standard).
diff --git a/src/flow_factory/data_utils/loader.py b/src/flow_factory/data_utils/loader.py
index 29e92a324..21fa42027 100644
--- a/src/flow_factory/data_utils/loader.py
+++ b/src/flow_factory/data_utils/loader.py
@@ -107,6 +107,7 @@ def _create_or_load_dataset(
preprocess_func=kwargs.get("preprocess_func"),
preprocess_kwargs=kwargs.get("preprocess_kwargs"),
extra_hash_strs=kwargs.get("extra_hash_strs", []),
+ source_hash_override=kwargs.get("source_hash_override"),
)
if os.path.exists(merged_cache_path) and not base_kwargs.get("force_reprocess", False):
diff --git a/src/flow_factory/data_utils/offline_condition_cache.py b/src/flow_factory/data_utils/offline_condition_cache.py
new file mode 100644
index 000000000..1bb6ec724
--- /dev/null
+++ b/src/flow_factory/data_utils/offline_condition_cache.py
@@ -0,0 +1,266 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Project V2 inputs into model condition preprocessing and caching.
+
+Offline supervision has a deliberately separate lifecycle from input
+conditions. This module exposes only ``NormalizedDatasetRecord.model_input`` to
+``GeneralDataset``. Target, chosen, rejected, and record metadata are never
+inserted into the raw Arrow table and therefore cannot leak into an adapter or
+invalidate an input-condition cache.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from typing import Any, Dict, List, Mapping, Sequence
+
+from datasets import Dataset as HFDataset
+
+from ..samples.references import canonicalize_reference_manifest
+from .dataset import (
+ METADATA_COLUMN,
+ GeneralDataset,
+ PreprocessCallable,
+ _supports_ordered_references,
+)
+from .offline_dataset import (
+ OFFLINE_CONDITION_ID_COLUMN,
+ compute_offline_condition_id,
+)
+from .schema import MediaAsset, NormalizedDatasetRecord
+
+_CONDITION_SOURCE_FORMAT = "flow-factory-offline-condition-v1"
+
+
+def project_offline_condition_dataset(
+ records: Sequence[NormalizedDatasetRecord],
+ *,
+ source_name: str,
+ ordered_references: bool,
+) -> HFDataset:
+ """Build an input-only raw dataset for ``GeneralDataset`` preprocessing.
+
+ Grouped adapters receive ``prompt`` plus per-modality ``images``, ``videos``,
+ and ``audios`` columns. Ordered-reference adapters receive one canonical JSON
+ string per row instead of an Arrow list-of-struct column. The string is
+ restored to validated legacy ``kind`` entries only at the adapter preprocess
+ boundary, avoiding Arrow's heterogeneous-struct null-key expansion.
+ """
+ if not isinstance(ordered_references, bool):
+ raise TypeError(
+ "ordered_references must be a bool, " f"got {type(ordered_references).__name__}"
+ )
+ stable_records = tuple(records)
+ if not stable_records:
+ raise ValueError("offline condition projection requires at least one record")
+ for index, record in enumerate(stable_records):
+ if not isinstance(record, NormalizedDatasetRecord):
+ raise TypeError(
+ "offline condition projection accepts normalized V2 records only, "
+ f"got {type(record).__name__} at index {index}"
+ )
+
+ condition_ids = [
+ compute_offline_condition_id(
+ record,
+ index=index,
+ source_name=source_name,
+ )
+ for index, record in enumerate(stable_records)
+ ]
+ columns: Dict[str, List[Any]] = {
+ "prompt": [record.model_input.prompt for record in stable_records],
+ OFFLINE_CONDITION_ID_COLUMN: condition_ids,
+ }
+
+ negative_prompts = [record.model_input.negative_prompt for record in stable_records]
+ if any(value is not None for value in negative_prompts):
+ columns["negative_prompt"] = negative_prompts
+
+ if ordered_references:
+ columns["references"] = [
+ canonicalize_reference_manifest(
+ [_to_legacy_reference(asset) for asset in record.model_input.media],
+ row_index=index,
+ )
+ for index, record in enumerate(stable_records)
+ ]
+ else:
+ grouped_columns = {
+ "images": [
+ [asset.path for asset in record.model_input.media if asset.type == "image"]
+ for record in stable_records
+ ],
+ "videos": [
+ [
+ _to_grouped_rate_spec(asset, rate_name="fps")
+ for asset in record.model_input.media
+ if asset.type == "video"
+ ]
+ for record in stable_records
+ ],
+ "audios": [
+ [
+ _to_grouped_rate_spec(asset, rate_name="sample_rate")
+ for asset in record.model_input.media
+ if asset.type == "audio"
+ ]
+ for record in stable_records
+ ],
+ }
+ columns.update(
+ {column_name: values for column_name, values in grouped_columns.items() if any(values)}
+ )
+
+ return HFDataset.from_dict(columns)
+
+
+def compute_offline_condition_source_hash(condition_ids: Sequence[str]) -> str:
+ """Hash ordered input identities for an input-only cache fingerprint."""
+ stable_ids = tuple(condition_ids)
+ if not stable_ids:
+ raise ValueError("offline condition source hash requires at least one condition id")
+ for index, condition_id in enumerate(stable_ids):
+ if not isinstance(condition_id, str) or not condition_id:
+ raise ValueError(
+ "offline condition ids must be non-empty strings, "
+ f"got {condition_id!r} at index {index}"
+ )
+ payload = json.dumps(
+ {
+ "format": _CONDITION_SOURCE_FORMAT,
+ "condition_ids": stable_ids,
+ },
+ ensure_ascii=False,
+ allow_nan=False,
+ separators=(",", ":"),
+ )
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def build_offline_condition_cache(
+ records: Sequence[NormalizedDatasetRecord],
+ *,
+ source_name: str,
+ dataset_dir: str | os.PathLike[str],
+ preprocess_func: PreprocessCallable,
+ preprocess_kwargs: Mapping[str, Any] | None = None,
+ cache_dir: str | os.PathLike[str] = "~/.cache/flow_factory/datasets",
+ force_reprocess: bool = False,
+ preprocessing_batch_size: int | None = None,
+ extra_hash_strs: Sequence[str] | None = None,
+ target_arrow_path: str | os.PathLike[str] | None = None,
+) -> HFDataset:
+ """Preprocess and cache only offline input conditions.
+
+ The default Arrow target is derived from the same input-only merged-cache
+ fingerprint used by ``GeneralDataset``. Rebuilding from records whose target
+ or metadata changed therefore loads the existing cache without invoking the
+ adapter again. Callers orchestrating distributed preprocessing may provide a
+ rank-specific ``target_arrow_path`` instead.
+ """
+ if not callable(preprocess_func):
+ raise TypeError(
+ "offline condition cache requires a callable preprocess_func, "
+ f"got {type(preprocess_func).__name__}"
+ )
+ ordered_references = _supports_ordered_references(preprocess_func)
+ raw_dataset = project_offline_condition_dataset(
+ records,
+ source_name=source_name,
+ ordered_references=ordered_references,
+ )
+ condition_ids = tuple(raw_dataset[OFFLINE_CONDITION_ID_COLUMN])
+ source_hash = compute_offline_condition_source_hash(condition_ids)
+ normalized_dataset_dir = os.path.expanduser(os.fspath(dataset_dir))
+ normalized_cache_dir = os.path.expanduser(os.fspath(cache_dir))
+ normalized_preprocess_kwargs = dict(preprocess_kwargs or {})
+ normalized_extra_hash_strs = list(extra_hash_strs or ())
+
+ if preprocessing_batch_size is None:
+ preprocessing_batch_size = 1 if ordered_references else 16
+ if (
+ not isinstance(preprocessing_batch_size, int)
+ or isinstance(preprocessing_batch_size, bool)
+ or preprocessing_batch_size <= 0
+ ):
+ raise ValueError(
+ "preprocessing_batch_size must be a positive integer, "
+ f"got {preprocessing_batch_size!r}"
+ )
+
+ normalized_target_arrow_path: str
+ if target_arrow_path is None:
+ merged_cache_path = GeneralDataset.compute_cache_path(
+ dataset_dir=normalized_dataset_dir,
+ split="train",
+ cache_dir=normalized_cache_dir,
+ max_dataset_size=None,
+ preprocess_func=preprocess_func,
+ preprocess_kwargs=normalized_preprocess_kwargs,
+ extra_hash_strs=normalized_extra_hash_strs,
+ source_hash_override=source_hash,
+ )
+ normalized_target_arrow_path = f"{merged_cache_path}.arrow"
+ else:
+ normalized_target_arrow_path = os.path.expanduser(os.fspath(target_arrow_path))
+
+ dataset_builder = GeneralDataset(
+ dataset_dir=normalized_dataset_dir,
+ split="train",
+ cache_dir=normalized_cache_dir,
+ preprocess_func=preprocess_func,
+ preprocess_kwargs=normalized_preprocess_kwargs,
+ preprocessing_batch_size=preprocessing_batch_size,
+ force_reprocess=force_reprocess,
+ extra_hash_strs=normalized_extra_hash_strs,
+ image_dir=normalized_dataset_dir,
+ video_dir=normalized_dataset_dir,
+ audio_dir=normalized_dataset_dir,
+ target_arrow_path=normalized_target_arrow_path,
+ raw_dataset=raw_dataset,
+ source_hash_override=source_hash,
+ passthrough_columns=(OFFLINE_CONDITION_ID_COLUMN,),
+ )
+ condition_cache = dataset_builder.processed_dataset
+ if METADATA_COLUMN in condition_cache.column_names:
+ condition_cache = condition_cache.remove_columns(METADATA_COLUMN)
+ return condition_cache
+
+
+def _to_legacy_reference(asset: MediaAsset) -> Dict[str, Any]:
+ reference: Dict[str, Any] = {"kind": asset.type, "path": asset.path}
+ if asset.type == "video" and asset.fps is not None:
+ reference["fps"] = asset.fps
+ elif asset.type == "audio" and asset.sample_rate is not None:
+ reference["sample_rate"] = asset.sample_rate
+ return reference
+
+
+def _to_grouped_rate_spec(asset: MediaAsset, *, rate_name: str) -> Dict[str, Any]:
+ spec: Dict[str, Any] = {"path": asset.path}
+ rate = getattr(asset, rate_name)
+ if rate is not None:
+ spec[rate_name] = rate
+ return spec
+
+
+__all__ = [
+ "build_offline_condition_cache",
+ "compute_offline_condition_source_hash",
+ "project_offline_condition_dataset",
+]
diff --git a/src/flow_factory/data_utils/offline_dataset.py b/src/flow_factory/data_utils/offline_dataset.py
new file mode 100644
index 000000000..c38081f78
--- /dev/null
+++ b/src/flow_factory/data_utils/offline_dataset.py
@@ -0,0 +1,770 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Offline records, on-demand target decoding, and explicit collation.
+
+The input-condition cache and output supervision intentionally have different
+lifecycles. Prompt and condition encodings are fetched from a cache by stable
+record index, while target, chosen, and rejected media are decoded from their
+source paths on every ``__getitem__`` call. This module never caches output
+pixels or model-specific output latents and never holds a model adapter.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import inspect
+import json
+import os
+import pickle
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any, Callable, Dict, List, Literal, Mapping, Sequence, Union
+
+import numpy as np
+import torch
+from PIL import Image
+from pydantic import ValidationError
+from torch.utils.data import Dataset
+
+try:
+ import av
+except ImportError:
+ av = None
+
+from .schema import (
+ DatasetRecordV2,
+ DemonstrationSupervision,
+ MediaAsset,
+ MediaType,
+ NormalizedDatasetRecord,
+ NormalizedModelInput,
+ NormalizedOutputCandidate,
+ PreferenceSupervision,
+ normalize_v2_record,
+)
+
+OfflineSupervisionType = Literal["demonstration", "preference"]
+MediaDecoder = Callable[[MediaAsset], Any]
+ConditionCache = Union[Dataset, Sequence[Mapping[str, Any]]]
+OFFLINE_CONDITION_ID_COLUMN = "__offline_condition_id__"
+
+
+@dataclass(frozen=True, slots=True)
+class DecodedMedia:
+ """One source media asset decoded on the CPU for the current item access."""
+
+ type: MediaType
+ path: str
+ payload: Any
+ fps: float | None = None
+ sample_rate: int | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class DemonstrationOutput:
+ """Decoded output for one demonstration item."""
+
+ target_media: tuple[DecodedMedia, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class PreferenceOutput:
+ """Decoded pairwise output for one preference item."""
+
+ chosen_media: tuple[DecodedMedia, ...]
+ rejected_media: tuple[DecodedMedia, ...]
+
+
+DecodedOutput = Union[DemonstrationOutput, PreferenceOutput]
+
+
+@dataclass(frozen=True, slots=True)
+class OfflineItem:
+ """One cached input condition paired with freshly decoded supervision."""
+
+ condition: Mapping[str, Any]
+ condition_id: str
+ record_id: str
+ source: str
+ source_id: int
+ model_input: NormalizedModelInput
+ supervision_type: OfflineSupervisionType
+ output: DecodedOutput
+ metadata_json: str
+
+
+@dataclass(frozen=True, slots=True)
+class DemonstrationOutputBatch:
+ """Ragged sample-to-ordered-media demonstration outputs."""
+
+ target_media: tuple[tuple[DecodedMedia, ...], ...]
+
+
+@dataclass(frozen=True, slots=True)
+class PreferenceOutputBatch:
+ """Ragged sample-to-ordered-media pairwise outputs."""
+
+ chosen_media: tuple[tuple[DecodedMedia, ...], ...]
+ rejected_media: tuple[tuple[DecodedMedia, ...], ...]
+
+
+DecodedOutputBatch = Union[DemonstrationOutputBatch, PreferenceOutputBatch]
+
+
+@dataclass(frozen=True, slots=True)
+class OfflineBatch:
+ """Explicit offline batch without model- or algorithm-specific tensor fields."""
+
+ condition: Dict[str, Any]
+ condition_ids: tuple[str, ...]
+ record_ids: tuple[str, ...]
+ sources: tuple[str, ...]
+ source_ids: torch.Tensor
+ model_inputs: tuple[NormalizedModelInput, ...]
+ supervision_type: OfflineSupervisionType
+ output: DecodedOutputBatch
+ metadata_json: tuple[str, ...]
+
+
+def load_offline_manifest(
+ manifest_path: str | os.PathLike[str],
+ *,
+ supervision_type: OfflineSupervisionType,
+ dataset_dir: str | os.PathLike[str] | None = None,
+) -> tuple[NormalizedDatasetRecord, ...]:
+ """Strictly parse and normalize one homogeneous offline JSONL split.
+
+ Args:
+ manifest_path: Path to the split JSONL manifest.
+ supervision_type: Required supervision discriminator for every row.
+ dataset_dir: Root for relative media paths. Defaults to the manifest's
+ parent directory.
+
+ Returns:
+ Immutable normalized records in manifest order.
+
+ Raises:
+ ValueError: If the manifest is empty, a line is blank or invalid, or a
+ row does not carry the required homogeneous supervision type.
+ """
+ _validate_supervision_type(supervision_type)
+ resolved_manifest_path = os.path.abspath(os.path.expanduser(os.fspath(manifest_path)))
+ resolved_dataset_dir = (
+ os.path.dirname(resolved_manifest_path)
+ if dataset_dir is None
+ else os.path.abspath(os.path.expanduser(os.fspath(dataset_dir)))
+ )
+ records: List[NormalizedDatasetRecord] = []
+
+ with open(resolved_manifest_path, encoding="utf-8") as manifest_file:
+ for line_number, line in enumerate(manifest_file, start=1):
+ context = f"{resolved_manifest_path}:{line_number}"
+ if not line.strip():
+ raise ValueError(f"invalid offline JSONL record at {context}: blank line")
+ try:
+ raw_record = json.loads(line)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"invalid offline JSONL record at {context}: {exc.msg}") from exc
+ try:
+ parsed_record = DatasetRecordV2.model_validate(raw_record)
+ except ValidationError as exc:
+ raise ValueError(f"invalid offline V2 record at {context}: {exc}") from exc
+
+ normalized_record = normalize_v2_record(
+ parsed_record,
+ dataset_dir=resolved_dataset_dir,
+ )
+ _require_record_supervision(
+ normalized_record,
+ supervision_type,
+ context=context,
+ )
+ records.append(normalized_record)
+
+ if not records:
+ raise ValueError(f"offline JSONL manifest is empty: {resolved_manifest_path}")
+ return tuple(records)
+
+
+class OfflineDataset(Dataset):
+ """Bind normalized offline records to a separately built condition cache.
+
+ Every condition-cache row must carry :data:`OFFLINE_CONDITION_ID_COLUMN`.
+ Its input-only identity must match ``records[index]`` before the cache is
+ accepted and is checked again when the row is fetched. Plain list and tuple
+ caches are snapshotted so caller-side reordering cannot change the binding.
+ """
+
+ def __init__(
+ self,
+ records: Sequence[NormalizedDatasetRecord],
+ condition_cache: ConditionCache,
+ *,
+ source_name: str,
+ source_id: int,
+ supervision_type: OfflineSupervisionType,
+ media_decoders: Mapping[MediaType, MediaDecoder] | None = None,
+ ) -> None:
+ _validate_supervision_type(supervision_type)
+ if not isinstance(source_name, str) or not source_name.strip():
+ raise ValueError(f"offline source_name must be a non-empty string, got {source_name!r}")
+ if not isinstance(source_id, int) or isinstance(source_id, bool) or source_id < 0:
+ raise ValueError(f"offline source_id must be a non-negative integer, got {source_id!r}")
+ normalized_records = tuple(records)
+ stable_condition_cache = (
+ tuple(condition_cache)
+ if isinstance(condition_cache, (list, tuple))
+ else condition_cache
+ )
+ if not normalized_records:
+ raise ValueError("offline dataset requires at least one normalized record")
+ if len(normalized_records) != len(stable_condition_cache):
+ raise ValueError(
+ "offline records and condition cache must have equal length, "
+ f"got {len(normalized_records)} and {len(stable_condition_cache)}"
+ )
+
+ for index, record in enumerate(normalized_records):
+ if not isinstance(record, NormalizedDatasetRecord):
+ raise TypeError(
+ "offline dataset accepts normalized V2 records only, "
+ f"got {type(record).__name__} at index {index}"
+ )
+ _require_record_supervision(
+ record,
+ supervision_type,
+ context=f"offline dataset index {index}",
+ )
+
+ decoders: Dict[MediaType, MediaDecoder] = dict(DEFAULT_MEDIA_DECODERS)
+ if media_decoders is not None:
+ for media_type, decoder in media_decoders.items():
+ if media_type not in ("image", "video", "audio"):
+ raise ValueError(f"unsupported media decoder type: {media_type!r}")
+ _require_picklable_unbound_decoder(media_type, decoder)
+ decoders[media_type] = decoder
+ _require_supervision_decoder_coverage(
+ normalized_records,
+ decoders,
+ source_name=source_name,
+ )
+
+ condition_ids: List[str] = []
+ record_ids: List[str] = []
+ for index, record in enumerate(normalized_records):
+ condition_id = compute_offline_condition_id(
+ record,
+ index=index,
+ source_name=source_name,
+ )
+ record_id = compute_offline_record_id(
+ record,
+ index=index,
+ source_name=source_name,
+ )
+ _extract_condition(
+ stable_condition_cache[index],
+ expected_condition_id=condition_id,
+ index=index,
+ mismatch_error=ValueError,
+ )
+ condition_ids.append(condition_id)
+ record_ids.append(record_id)
+
+ self._records = normalized_records
+ self._condition_cache = stable_condition_cache
+ self._condition_cache_length = len(stable_condition_cache)
+ self._condition_ids = tuple(condition_ids)
+ self._record_ids = tuple(record_ids)
+ self._media_decoders = decoders
+ self.source_name = source_name
+ self.source_id = source_id
+ self.supervision_type = supervision_type
+
+ def __len__(self) -> int:
+ return len(self._records)
+
+ def __getitem__(self, index: int) -> OfflineItem:
+ if not isinstance(index, int) or isinstance(index, bool):
+ raise TypeError(f"offline dataset index must be an integer, got {index!r}")
+ if index < 0:
+ index += len(self._records)
+ if index < 0 or index >= len(self._records):
+ raise IndexError(f"offline dataset index out of range: {index}")
+ if len(self._condition_cache) != self._condition_cache_length:
+ raise RuntimeError(
+ "condition cache length changed after offline dataset construction; "
+ "stable index binding is no longer guaranteed"
+ )
+ record = self._records[index]
+ condition_id = self._condition_ids[index]
+ record_id = self._record_ids[index]
+ condition = _extract_condition(
+ self._condition_cache[index],
+ expected_condition_id=condition_id,
+ index=index,
+ mismatch_error=RuntimeError,
+ )
+
+ supervision = record.supervision
+ if self.supervision_type == "demonstration":
+ if not isinstance(supervision, DemonstrationSupervision):
+ raise RuntimeError(
+ f"offline dataset supervision changed unexpectedly at index {index}"
+ )
+ output: DecodedOutput = DemonstrationOutput(
+ target_media=self._decode_candidate(supervision.target)
+ )
+ else:
+ if not isinstance(supervision, PreferenceSupervision):
+ raise RuntimeError(
+ f"offline dataset supervision changed unexpectedly at index {index}"
+ )
+ output = PreferenceOutput(
+ chosen_media=self._decode_candidate(supervision.chosen),
+ rejected_media=self._decode_candidate(supervision.rejected),
+ )
+
+ return OfflineItem(
+ condition=condition,
+ condition_id=condition_id,
+ record_id=record_id,
+ source=self.source_name,
+ source_id=self.source_id,
+ model_input=record.model_input,
+ supervision_type=self.supervision_type,
+ output=output,
+ metadata_json=record.metadata_json,
+ )
+
+ def _decode_candidate(
+ self,
+ candidate: NormalizedOutputCandidate,
+ ) -> tuple[DecodedMedia, ...]:
+ return tuple(self._decode_media(asset) for asset in candidate.media)
+
+ def _decode_media(self, asset: MediaAsset) -> DecodedMedia:
+ decoder = self._media_decoders.get(asset.type)
+ if decoder is None:
+ raise NotImplementedError(
+ f"offline target media type {asset.type!r} has no decoder for {asset.path!r}; "
+ "inject a module-level media decoder explicitly"
+ )
+ payload = decoder(asset)
+ return DecodedMedia(
+ type=asset.type,
+ path=asset.path,
+ payload=payload,
+ fps=asset.fps,
+ sample_rate=asset.sample_rate,
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class OfflineCollator:
+ """Collate one known offline supervision branch without row-based guessing."""
+
+ supervision_type: OfflineSupervisionType
+
+ def __post_init__(self) -> None:
+ _validate_supervision_type(self.supervision_type)
+
+ def __call__(self, items: Sequence[OfflineItem]) -> OfflineBatch:
+ if not items:
+ raise ValueError("cannot collate an empty offline batch")
+ for index, item in enumerate(items):
+ if not isinstance(item, OfflineItem):
+ raise TypeError(
+ f"offline collator expected OfflineItem at index {index}, "
+ f"got {type(item).__name__}"
+ )
+ if item.supervision_type != self.supervision_type:
+ raise ValueError(
+ "offline collator supervision mismatch at batch index "
+ f"{index}: expected {self.supervision_type!r}, "
+ f"got {item.supervision_type!r}"
+ )
+
+ if self.supervision_type == "demonstration":
+ target_media: List[tuple[DecodedMedia, ...]] = []
+ for index, item in enumerate(items):
+ if not isinstance(item.output, DemonstrationOutput):
+ raise TypeError(
+ "demonstration collator expected DemonstrationOutput at batch index "
+ f"{index}, got {type(item.output).__name__}"
+ )
+ target_media.append(item.output.target_media)
+ output: DecodedOutputBatch = DemonstrationOutputBatch(target_media=tuple(target_media))
+ else:
+ chosen_media: List[tuple[DecodedMedia, ...]] = []
+ rejected_media: List[tuple[DecodedMedia, ...]] = []
+ for index, item in enumerate(items):
+ if not isinstance(item.output, PreferenceOutput):
+ raise TypeError(
+ "preference collator expected PreferenceOutput at batch index "
+ f"{index}, got {type(item.output).__name__}"
+ )
+ chosen_media.append(item.output.chosen_media)
+ rejected_media.append(item.output.rejected_media)
+ output = PreferenceOutputBatch(
+ chosen_media=tuple(chosen_media),
+ rejected_media=tuple(rejected_media),
+ )
+
+ return OfflineBatch(
+ condition=_collate_condition_mappings([item.condition for item in items]),
+ condition_ids=tuple(item.condition_id for item in items),
+ record_ids=tuple(item.record_id for item in items),
+ sources=tuple(item.source for item in items),
+ source_ids=torch.tensor([item.source_id for item in items], dtype=torch.long),
+ model_inputs=tuple(item.model_input for item in items),
+ supervision_type=self.supervision_type,
+ output=output,
+ metadata_json=tuple(item.metadata_json for item in items),
+ )
+
+
+def decode_image(asset: MediaAsset) -> Image.Image:
+ """Decode one target image as detached RGB pixels on the CPU.
+
+ Args:
+ asset: Normalized image reference with a resolved local path.
+
+ Returns:
+ Detached RGB PIL image.
+
+ Raises:
+ ValueError: If PIL cannot decode the target image.
+ """
+ try:
+ with Image.open(asset.path) as image:
+ return image.convert("RGB")
+ except (OSError, ValueError) as exc:
+ raise ValueError(f"failed to decode target image {asset.path!r}: {exc}") from exc
+
+
+def decode_video(asset: MediaAsset) -> np.ndarray:
+ """Decode one target video into native-rate RGB frames on the CPU.
+
+ The returned ``uint8`` array has shape ``(frames, height, width, 3)`` and is
+ accepted directly by Diffusers ``VideoProcessor.preprocess_video``. Temporal
+ sampling, spatial resizing, and model-specific geometry remain adapter-owned.
+ Keeping this function at module scope makes the default decoder safe to pickle
+ under spawn-based DataLoader workers.
+
+ Args:
+ asset: Normalized video reference with a resolved local path.
+
+ Returns:
+ Contiguous ``uint8`` RGB array shaped ``(frames, height, width, 3)``.
+
+ Raises:
+ ImportError: If PyAV is unavailable.
+ ValueError: If the container, stream, or decoded geometry is invalid.
+ """
+ if av is None:
+ raise ImportError(
+ "offline target video decoding requires PyAV>=18.0.0; "
+ "install with `pip install 'av>=18.0.0'`"
+ )
+ try:
+ with av.open(asset.path) as container:
+ if not container.streams.video:
+ raise ValueError("container has no video stream")
+ stream = container.streams.video[0]
+ frames = [frame.to_ndarray(format="rgb24") for frame in container.decode(stream)]
+ except (OSError, ValueError, av.error.FFmpegError) as exc:
+ raise ValueError(f"failed to decode target video {asset.path!r}: {exc}") from exc
+
+ if not frames:
+ raise ValueError(f"failed to decode target video {asset.path!r}: decoded no frames")
+ try:
+ video = np.stack(frames, axis=0)
+ except ValueError as exc:
+ raise ValueError(
+ f"failed to decode target video {asset.path!r}: decoded frames have inconsistent geometry"
+ ) from exc
+ if video.ndim != 4 or video.shape[-1] != 3:
+ raise ValueError(
+ f"failed to decode target video {asset.path!r}: expected RGB frames shaped "
+ f"(F,H,W,3), received {video.shape}"
+ )
+ return np.ascontiguousarray(video, dtype=np.uint8)
+
+
+DEFAULT_MEDIA_DECODERS: Mapping[MediaType, MediaDecoder] = MappingProxyType(
+ {
+ "image": decode_image,
+ "video": decode_video,
+ }
+)
+
+
+def compute_offline_condition_id(
+ record: NormalizedDatasetRecord,
+ *,
+ index: int,
+ source_name: str,
+) -> str:
+ """Build an input-only identity for one prompt/condition cache row."""
+ _validate_identity_inputs(record, index=index, source_name=source_name)
+ identity_payload = {
+ "source_name": source_name,
+ "index": index,
+ "schema_version": record.schema_version,
+ "input": {
+ "prompt": record.model_input.prompt,
+ "negative_prompt": record.model_input.negative_prompt,
+ "media": [_media_identity(media) for media in record.model_input.media],
+ },
+ }
+ return _hash_identity(identity_payload)
+
+
+def compute_offline_record_id(
+ record: NormalizedDatasetRecord,
+ *,
+ index: int,
+ source_name: str,
+) -> str:
+ """Build a full provenance identity including supervision and metadata."""
+ condition_id = compute_offline_condition_id(
+ record,
+ index=index,
+ source_name=source_name,
+ )
+ supervision = record.supervision
+ if isinstance(supervision, DemonstrationSupervision):
+ supervision_payload: Any = {
+ "type": "demonstration",
+ "target": _candidate_identity(supervision.target),
+ }
+ elif isinstance(supervision, PreferenceSupervision):
+ supervision_payload = {
+ "type": "preference",
+ "chosen": _candidate_identity(supervision.chosen),
+ "rejected": _candidate_identity(supervision.rejected),
+ }
+ else:
+ supervision_payload = None
+ return _hash_identity(
+ {
+ "condition_id": condition_id,
+ "supervision": supervision_payload,
+ "metadata_json": record.metadata_json,
+ }
+ )
+
+
+def _validate_identity_inputs(
+ record: NormalizedDatasetRecord,
+ *,
+ index: int,
+ source_name: str,
+) -> None:
+ if not isinstance(record, NormalizedDatasetRecord):
+ raise TypeError(
+ "offline identity requires NormalizedDatasetRecord, " f"got {type(record).__name__}"
+ )
+ if not isinstance(index, int) or isinstance(index, bool) or index < 0:
+ raise ValueError(f"offline record index must be a non-negative integer, got {index!r}")
+ if not isinstance(source_name, str) or not source_name.strip():
+ raise ValueError(f"offline source_name must be a non-empty string, got {source_name!r}")
+
+
+def _hash_identity(identity_payload: Mapping[str, Any]) -> str:
+ canonical_identity = json.dumps(
+ identity_payload,
+ ensure_ascii=False,
+ allow_nan=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ return hashlib.sha256(canonical_identity.encode("utf-8")).hexdigest()
+
+
+def _candidate_identity(candidate: NormalizedOutputCandidate) -> Dict[str, Any]:
+ return {"media": [_media_identity(media) for media in candidate.media]}
+
+
+def _media_identity(media: MediaAsset) -> Dict[str, Any]:
+ return {
+ "type": media.type,
+ "path": media.path,
+ "fps": media.fps,
+ "sample_rate": media.sample_rate,
+ }
+
+
+def _extract_condition(
+ row: Any,
+ *,
+ expected_condition_id: str,
+ index: int,
+ mismatch_error: type[Exception],
+) -> Dict[str, Any]:
+ if not isinstance(row, Mapping):
+ raise mismatch_error(
+ "condition cache row must be a mapping at index " f"{index}, got {type(row).__name__}"
+ )
+ if OFFLINE_CONDITION_ID_COLUMN not in row:
+ raise mismatch_error(
+ f"condition cache row at index {index} is missing reserved identity column "
+ f"{OFFLINE_CONDITION_ID_COLUMN!r}"
+ )
+ actual_condition_id = row[OFFLINE_CONDITION_ID_COLUMN]
+ if actual_condition_id != expected_condition_id:
+ raise mismatch_error(
+ "condition cache identity mismatch at index "
+ f"{index}: expected {expected_condition_id!r}, got {actual_condition_id!r}"
+ )
+ return {key: value for key, value in row.items() if key != OFFLINE_CONDITION_ID_COLUMN}
+
+
+def _require_supervision_decoder_coverage(
+ records: Sequence[NormalizedDatasetRecord],
+ decoders: Mapping[MediaType, MediaDecoder],
+ *,
+ source_name: str,
+) -> None:
+ for record_index, record in enumerate(records):
+ supervision = record.supervision
+ candidates: tuple[tuple[str, NormalizedOutputCandidate], ...]
+ if isinstance(supervision, DemonstrationSupervision):
+ candidates = (("target", supervision.target),)
+ elif isinstance(supervision, PreferenceSupervision):
+ candidates = (
+ ("chosen", supervision.chosen),
+ ("rejected", supervision.rejected),
+ )
+ else:
+ continue
+ for candidate_name, candidate in candidates:
+ for media_index, asset in enumerate(candidate.media):
+ if asset.type not in decoders:
+ raise NotImplementedError(
+ f"offline source {source_name!r} dataset index {record_index} "
+ f"{candidate_name} media {media_index} at {asset.path!r} has type "
+ f"{asset.type!r} with no decoder; "
+ "inject a pickleable module-level function explicitly"
+ )
+
+
+def _collate_condition_mappings(
+ conditions: Sequence[Mapping[str, Any]],
+) -> Dict[str, Any]:
+ expected_keys = tuple(conditions[0].keys())
+ expected_key_set = set(expected_keys)
+ for index, condition in enumerate(conditions):
+ if set(condition.keys()) != expected_key_set:
+ raise ValueError(
+ "condition cache mappings must expose identical keys within a batch; "
+ f"expected {sorted(expected_key_set)!r}, got "
+ f"{sorted(condition.keys())!r} at batch index {index}"
+ )
+
+ collated: Dict[str, Any] = {}
+ for key in expected_keys:
+ values = [condition[key] for condition in conditions]
+ if all(isinstance(value, torch.Tensor) for value in values):
+ shapes = [value.shape for value in values]
+ collated[key] = (
+ torch.stack(values, dim=0)
+ if all(shape == shapes[0] for shape in shapes)
+ else values
+ )
+ elif any(isinstance(value, torch.Tensor) for value in values) and any(
+ isinstance(value, list) for value in values
+ ):
+ collated[key] = [
+ list(torch.unbind(value, dim=0)) if isinstance(value, torch.Tensor) else value
+ for value in values
+ ]
+ else:
+ collated[key] = values
+ return collated
+
+
+def _validate_supervision_type(supervision_type: str) -> None:
+ if supervision_type not in ("demonstration", "preference"):
+ raise ValueError(
+ "offline supervision_type must be 'demonstration' or 'preference', "
+ f"got {supervision_type!r}"
+ )
+
+
+def _require_picklable_unbound_decoder(
+ media_type: MediaType,
+ decoder: MediaDecoder,
+) -> None:
+ if not inspect.isfunction(decoder):
+ raise TypeError(
+ f"decoder for media type {media_type!r} must be a module-level function, "
+ f"got {type(decoder).__name__}"
+ )
+ qualified_name = getattr(decoder, "__qualname__", "")
+ if qualified_name == "" or "" in qualified_name:
+ raise TypeError(
+ f"decoder for media type {media_type!r} must be defined at module scope for spawn"
+ )
+ try:
+ pickle.dumps(decoder)
+ except (AttributeError, pickle.PicklingError, TypeError) as exc:
+ raise TypeError(
+ f"decoder for media type {media_type!r} must be pickleable for spawn workers"
+ ) from exc
+
+
+def _require_record_supervision(
+ record: NormalizedDatasetRecord,
+ supervision_type: OfflineSupervisionType,
+ *,
+ context: str,
+) -> None:
+ supervision = record.supervision
+ if supervision is None:
+ raise ValueError(
+ f"{context} is prompt-only; offline datasets require {supervision_type!r} supervision"
+ )
+ actual_type: OfflineSupervisionType = (
+ "demonstration" if isinstance(supervision, DemonstrationSupervision) else "preference"
+ )
+ if actual_type != supervision_type:
+ raise ValueError(
+ f"{context} has {actual_type!r} supervision, expected homogeneous "
+ f"{supervision_type!r} supervision"
+ )
+
+
+__all__ = [
+ "DEFAULT_MEDIA_DECODERS",
+ "DecodedMedia",
+ "DemonstrationOutput",
+ "DemonstrationOutputBatch",
+ "MediaDecoder",
+ "OfflineBatch",
+ "OfflineCollator",
+ "OfflineDataset",
+ "OfflineItem",
+ "OfflineSupervisionType",
+ "OFFLINE_CONDITION_ID_COLUMN",
+ "PreferenceOutput",
+ "PreferenceOutputBatch",
+ "compute_offline_condition_id",
+ "compute_offline_record_id",
+ "decode_image",
+ "decode_video",
+ "load_offline_manifest",
+]
diff --git a/src/flow_factory/data_utils/offline_loader.py b/src/flow_factory/data_utils/offline_loader.py
new file mode 100644
index 000000000..7501b239a
--- /dev/null
+++ b/src/flow_factory/data_utils/offline_loader.py
@@ -0,0 +1,239 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Finite offline dataloaders using PyTorch's official distribution semantics."""
+
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from torch.utils.data import ConcatDataset, DataLoader, DistributedSampler
+
+from .offline_dataset import OfflineCollator, OfflineDataset
+
+OfflineDatasetCollection = Union[OfflineDataset, Sequence[OfflineDataset]]
+
+
+def build_offline_dataloader(
+ datasets: OfflineDatasetCollection,
+ *,
+ source_weights: Sequence[int | float],
+ batch_size: int,
+ num_replicas: int,
+ rank: int,
+ gradient_accumulation_steps: int,
+ num_workers: int = 0,
+ shuffle: bool = True,
+ seed: int = 0,
+ sampler_drop_last: bool = False,
+ batch_drop_last: bool = False,
+ pin_memory: bool = False,
+) -> DataLoader:
+ """Build one finite, already-distributed offline epoch loader.
+
+ Every source participates exactly once through a :class:`ConcatDataset`.
+ Weighted replacement would make "one complete dataloader traversal" stop
+ meaning one data epoch, so all source weights must be explicitly equal to
+ one. The returned loader is not passed through any Accelerator preparation;
+ its official :class:`DistributedSampler` already owns rank sharding.
+
+ The execution driver, not this builder, calls ``sampler.set_epoch`` before
+ each traversal.
+
+ Args:
+ datasets: One offline dataset or an ordered sequence of sources.
+ source_weights: One explicit unit weight per source.
+ batch_size: Per-rank DataLoader batch size.
+ num_replicas: Total distributed process count, including one-process runs.
+ rank: Current global process rank.
+ gradient_accumulation_steps: Complete batch windows required per epoch.
+ num_workers: CPU DataLoader worker count.
+ shuffle: Whether the official sampler shuffles each epoch.
+ seed: Shared sampler seed.
+ sampler_drop_last: Whether the official sampler drops the dataset tail
+ that cannot be divided evenly across ranks.
+ batch_drop_last: Whether DataLoader drops a rank-local incomplete batch.
+ pin_memory: Whether DataLoader tensors use pinned host memory. Defaults
+ to ``False``, which is safe for macOS and MPS.
+
+ Returns:
+ A finite DataLoader with ``DistributedSampler`` at ``loader.sampler``.
+
+ Raises:
+ TypeError: If an argument has the wrong runtime type.
+ ValueError: If source contracts or batch geometry are invalid.
+ """
+ offline_datasets = _normalize_datasets(datasets)
+ _validate_source_weights(source_weights, source_count=len(offline_datasets))
+ _require_positive_int(batch_size, "batch_size")
+ _require_positive_int(num_replicas, "num_replicas")
+ _require_non_negative_int(rank, "rank")
+ if rank >= num_replicas:
+ raise ValueError(
+ f"rank must satisfy 0 <= rank < num_replicas, got "
+ f"rank={rank}, num_replicas={num_replicas}"
+ )
+ _require_positive_int(gradient_accumulation_steps, "gradient_accumulation_steps")
+ _require_non_negative_int(num_workers, "num_workers")
+ _require_int(seed, "seed")
+ _require_bool(shuffle, "shuffle")
+ _require_bool(sampler_drop_last, "sampler_drop_last")
+ _require_bool(batch_drop_last, "batch_drop_last")
+ _require_bool(pin_memory, "pin_memory")
+
+ supervision_type = offline_datasets[0].supervision_type
+ for source_index, dataset in enumerate(offline_datasets[1:], start=1):
+ if dataset.supervision_type != supervision_type:
+ raise ValueError(
+ "offline sources must share one supervision_type, "
+ f"source 0 has {supervision_type!r} while source {source_index} "
+ f"has {dataset.supervision_type!r}"
+ )
+ _validate_unique_source_identities(offline_datasets)
+
+ concatenated = ConcatDataset(offline_datasets)
+ sampler = DistributedSampler(
+ concatenated,
+ num_replicas=num_replicas,
+ rank=rank,
+ shuffle=shuffle,
+ seed=seed,
+ drop_last=sampler_drop_last,
+ )
+ loader = DataLoader(
+ concatenated,
+ batch_size=batch_size,
+ sampler=sampler,
+ num_workers=num_workers,
+ pin_memory=pin_memory,
+ drop_last=batch_drop_last,
+ collate_fn=OfflineCollator(supervision_type),
+ )
+
+ num_batches = len(loader)
+ if num_batches == 0:
+ raise ValueError(
+ "offline dataloader is empty on this rank; adjust dataset size, "
+ "batch_size, num_replicas, sampler_drop_last, or batch_drop_last"
+ )
+ if num_batches % gradient_accumulation_steps != 0:
+ raise ValueError(
+ f"offline dataloader yields {num_batches} batches on rank {rank}, which is not "
+ f"divisible by gradient_accumulation_steps={gradient_accumulation_steps}. "
+ "Offline epochs do not pad the loader or implicitly flush a partial gradient-"
+ "accumulation window; adjust dataset size, batch_size, num_replicas, "
+ "sampler_drop_last, batch_drop_last, or gradient_accumulation_steps explicitly."
+ )
+ return loader
+
+
+def _normalize_datasets(datasets: OfflineDatasetCollection) -> tuple[OfflineDataset, ...]:
+ if isinstance(datasets, OfflineDataset):
+ normalized = (datasets,)
+ else:
+ if isinstance(datasets, (str, bytes)):
+ raise TypeError("datasets must be OfflineDataset instances, not a string")
+ try:
+ normalized = tuple(datasets)
+ except TypeError as exc:
+ raise TypeError(
+ "datasets must be one OfflineDataset or a sequence of OfflineDataset instances"
+ ) from exc
+ if not normalized:
+ raise ValueError("offline dataloader requires at least one OfflineDataset source")
+ for source_index, dataset in enumerate(normalized):
+ if not isinstance(dataset, OfflineDataset):
+ raise TypeError(
+ f"offline source {source_index} must be OfflineDataset, "
+ f"got {type(dataset).__name__}"
+ )
+ return normalized
+
+
+def _validate_source_weights(
+ source_weights: Sequence[int | float],
+ *,
+ source_count: int,
+) -> None:
+ if isinstance(source_weights, (str, bytes)):
+ raise TypeError("source_weights must be a numeric sequence")
+ try:
+ weights = tuple(source_weights)
+ except TypeError as exc:
+ raise TypeError("source_weights must be a numeric sequence") from exc
+ if len(weights) != source_count:
+ raise ValueError(
+ f"source_weights must contain one entry per offline source, "
+ f"got {len(weights)} weights for {source_count} sources"
+ )
+ for source_index, weight in enumerate(weights):
+ if type(weight) not in (int, float):
+ raise TypeError(
+ f"offline source weight {source_index} must be int or float, "
+ f"got {type(weight).__name__}: {weight!r}"
+ )
+ if weight != 1:
+ raise ValueError(
+ f"offline source weight {source_index} must equal 1, got {weight!r}; "
+ "weighted replacement is incompatible with a full-dataloader data epoch"
+ )
+
+
+def _validate_unique_source_identities(datasets: Sequence[OfflineDataset]) -> None:
+ source_names: dict[str, int] = {}
+ source_ids: dict[int, int] = {}
+ for source_index, dataset in enumerate(datasets):
+ previous_name_index = source_names.get(dataset.source_name)
+ if previous_name_index is not None:
+ raise ValueError(
+ f"offline source_name {dataset.source_name!r} is duplicated at source indices "
+ f"{previous_name_index} and {source_index}; source names must be unique for "
+ "provenance and metric routing"
+ )
+ previous_id_index = source_ids.get(dataset.source_id)
+ if previous_id_index is not None:
+ raise ValueError(
+ f"offline source_id {dataset.source_id} is duplicated at source indices "
+ f"{previous_id_index} and {source_index}; source ids must be unique for "
+ "provenance and metric routing"
+ )
+ source_names[dataset.source_name] = source_index
+ source_ids[dataset.source_id] = source_index
+
+
+def _require_positive_int(value: object, field_name: str) -> None:
+ if type(value) is not int:
+ raise TypeError(f"{field_name} must be int, got {type(value).__name__}: {value!r}")
+ if value < 1:
+ raise ValueError(f"{field_name} must be >= 1, got {value}")
+
+
+def _require_non_negative_int(value: object, field_name: str) -> None:
+ if type(value) is not int:
+ raise TypeError(f"{field_name} must be int, got {type(value).__name__}: {value!r}")
+ if value < 0:
+ raise ValueError(f"{field_name} must be >= 0, got {value}")
+
+
+def _require_int(value: object, field_name: str) -> None:
+ if type(value) is not int:
+ raise TypeError(f"{field_name} must be int, got {type(value).__name__}: {value!r}")
+
+
+def _require_bool(value: object, field_name: str) -> None:
+ if type(value) is not bool:
+ raise TypeError(f"{field_name} must be bool, got {type(value).__name__}: {value!r}")
+
+
+__all__ = ["build_offline_dataloader"]
diff --git a/src/flow_factory/data_utils/offline_train_data.py b/src/flow_factory/data_utils/offline_train_data.py
new file mode 100644
index 000000000..8391c8eca
--- /dev/null
+++ b/src/flow_factory/data_utils/offline_train_data.py
@@ -0,0 +1,470 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Build finite offline train data from normalized V2 dataset sources."""
+
+from __future__ import annotations
+
+import os
+from typing import Any, Literal, Mapping, Optional, Sequence
+
+from accelerate import Accelerator
+from datasets import Dataset as HFDataset
+from torch.utils.data import DataLoader
+
+from ..contracts import (
+ InputMediaBinding,
+ PipelineIOContract,
+ validate_pipeline_model_input,
+ validate_pipeline_output_candidate,
+)
+from ..hparams import Arguments
+from ..utils.base import filter_kwargs
+from .dataset import (
+ METADATA_COLUMN,
+ PreprocessCallable,
+ _supports_ordered_references,
+)
+from .loader import _create_or_load_dataset
+from .offline_condition_cache import (
+ compute_offline_condition_source_hash,
+ project_offline_condition_dataset,
+)
+from .offline_dataset import (
+ DEFAULT_MEDIA_DECODERS,
+ OFFLINE_CONDITION_ID_COLUMN,
+ MediaDecoder,
+ OfflineDataset,
+ OfflineSupervisionType,
+ load_offline_manifest,
+)
+from .offline_loader import build_offline_dataloader
+from .schema import (
+ DemonstrationSupervision,
+ MediaAsset,
+ MediaType,
+ NormalizedDatasetRecord,
+ PreferenceSupervision,
+)
+
+
+def build_offline_train_dataloader(
+ config: Arguments,
+ accelerator: Accelerator,
+ preprocess_func: PreprocessCallable,
+ *,
+ supervision_type: OfflineSupervisionType,
+ pipeline_io_contract: PipelineIOContract,
+ preprocess_kwargs: Optional[Mapping[str, Any]] = None,
+ extra_hash_strs: Optional[Sequence[str]] = None,
+ media_decoders: Optional[Mapping[MediaType, MediaDecoder]] = None,
+ shuffle: bool = True,
+ sampler_drop_last: bool = False,
+ batch_drop_last: bool = False,
+ pin_memory: bool = False,
+) -> DataLoader:
+ """Build one already-distributed offline train dataloader from config sources.
+
+ Every enabled training source is parsed from
+ ``{dataset_dir}/{train.split}.jsonl`` as homogeneous V2 supervision. Only the
+ normalized input projection enters distributed Arrow preprocessing; target,
+ chosen, rejected, and metadata fields remain attached to ``OfflineDataset`` and
+ are decoded on demand.
+
+ Args:
+ config: Fully resolved framework arguments.
+ accelerator: Accelerator used only for rank metadata and cache barriers.
+ preprocess_func: Adapter input-preprocessing callable.
+ supervision_type: Homogeneous supervision required from every source.
+ pipeline_io_contract: Adapter-owned input/output declaration used to reject
+ unsupported conditions before preprocessing.
+ preprocess_kwargs: Optional overrides layered onto config-derived train
+ preprocessing arguments.
+ extra_hash_strs: Additional input-cache fingerprint components.
+ media_decoders: Optional module-level target decoders by media type.
+ shuffle: Whether the official ``DistributedSampler`` shuffles each epoch.
+ sampler_drop_last: Whether the sampler drops its non-divisible rank tail.
+ batch_drop_last: Whether the DataLoader drops a rank-local partial batch.
+ pin_memory: Whether the DataLoader pins CPU tensors. Defaults to ``False``
+ for macOS and MPS safety.
+
+ Returns:
+ A finite DataLoader whose dataset retains only normalized records and
+ detached Hugging Face condition datasets, never a bound adapter wrapper.
+
+ Raises:
+ TypeError: If callable, mapping, or source fields have invalid types.
+ ValueError: If no source is enabled or offline source contracts disagree.
+ FileNotFoundError: If an enabled source split JSONL does not exist.
+ NotImplementedError: If target supervision needs an unavailable decoder.
+
+ Note:
+ The returned loader is already sharded by PyTorch ``DistributedSampler``
+ and must not be passed to ``Accelerator.prepare``.
+ """
+ if not callable(preprocess_func):
+ raise TypeError(
+ "offline train data requires a callable preprocess_func, "
+ f"got {type(preprocess_func).__name__}"
+ )
+ if not isinstance(pipeline_io_contract, PipelineIOContract):
+ raise TypeError(
+ "offline train data requires a PipelineIOContract, "
+ f"got {type(pipeline_io_contract).__name__}"
+ )
+ ordered_references = _supports_ordered_references(preprocess_func)
+ expected_ordered_references = (
+ pipeline_io_contract.input_media.binding is InputMediaBinding.ORDERED_REFERENCES
+ )
+ if ordered_references != expected_ordered_references:
+ raise ValueError(
+ "offline input preprocessing binding disagrees with pipeline contract: "
+ f"contract={pipeline_io_contract.input_media.binding.value!r}, "
+ f"preprocess supports_ordered_references={ordered_references}"
+ )
+ if preprocess_kwargs is not None and not isinstance(preprocess_kwargs, Mapping):
+ raise TypeError(
+ "preprocess_kwargs must be a mapping or None, "
+ f"got {type(preprocess_kwargs).__name__}"
+ )
+ if extra_hash_strs is not None and isinstance(extra_hash_strs, (str, bytes)):
+ raise TypeError("extra_hash_strs must be a sequence of strings, not a string")
+ if media_decoders is not None and not isinstance(media_decoders, Mapping):
+ raise TypeError(
+ "media_decoders must be a mapping or None, " f"got {type(media_decoders).__name__}"
+ )
+
+ data_args = config.data_args
+ training_args = config.training_args
+ if not data_args.enable_preprocess:
+ raise ValueError(
+ "offline train data requires data.enable_preprocess=True so cached rows contain "
+ "adapter input conditions rather than raw V2 projections"
+ )
+ training_sources = tuple(data_args.training_datasets)
+ _validate_training_sources(training_sources)
+
+ normalized_extra_hash_strs = _build_extra_hash_strs(config, extra_hash_strs)
+ normalized_preprocess_kwargs = _build_preprocess_kwargs(
+ config,
+ preprocess_func,
+ preprocess_kwargs,
+ )
+ available_decoder_types = _available_decoder_types(media_decoders)
+
+ source_records = []
+ for source in training_sources:
+ train_spec = source.train
+ if train_spec is None:
+ raise RuntimeError(
+ f"enabled offline source {source.name!r} unexpectedly has train=None"
+ )
+ dataset_dir = os.path.abspath(os.path.expanduser(os.fspath(source.dataset_dir)))
+ manifest_path = os.path.join(dataset_dir, f"{train_spec.split}.jsonl")
+ records = load_offline_manifest(
+ manifest_path,
+ supervision_type=supervision_type,
+ dataset_dir=dataset_dir,
+ )
+ max_dataset_size = (
+ train_spec.max_dataset_size
+ if train_spec.max_dataset_size is not None
+ else data_args.max_dataset_size
+ )
+ records = _slice_records(records, max_dataset_size, source_name=source.name)
+ _validate_pipeline_inputs(
+ records,
+ contract=pipeline_io_contract,
+ source_name=source.name,
+ )
+ _validate_pipeline_outputs(
+ records,
+ contract=pipeline_io_contract,
+ source_name=source.name,
+ )
+ _require_decoder_coverage(
+ records,
+ available_decoder_types,
+ source_name=source.name,
+ )
+ source_records.append((source, dataset_dir, records))
+
+ offline_datasets = []
+ for source, dataset_dir, records in source_records:
+ condition_cache = _build_distributed_condition_cache(
+ records,
+ source_name=source.name,
+ split=source.train.split,
+ dataset_dir=dataset_dir,
+ cache_dir=data_args.cache_dir,
+ preprocess_func=preprocess_func,
+ preprocess_kwargs=normalized_preprocess_kwargs,
+ preprocessing_batch_size=data_args.preprocessing_batch_size,
+ force_reprocess=data_args.force_reprocess,
+ extra_hash_strs=[*normalized_extra_hash_strs, f"offline_train_source:{source.name}"],
+ preprocess_parallelism=data_args.preprocess_parallelism,
+ accelerator=accelerator,
+ )
+ offline_datasets.append(
+ OfflineDataset(
+ records,
+ condition_cache,
+ source_name=source.name,
+ source_id=source.source_id,
+ supervision_type=supervision_type,
+ media_decoders=media_decoders,
+ )
+ )
+
+ return build_offline_dataloader(
+ offline_datasets,
+ source_weights=[source.train.weight for source in training_sources],
+ batch_size=training_args.per_device_batch_size,
+ num_replicas=accelerator.num_processes,
+ rank=accelerator.process_index,
+ gradient_accumulation_steps=training_args.gradient_accumulation_steps,
+ num_workers=data_args.dataloader_num_workers,
+ shuffle=shuffle,
+ seed=training_args.seed,
+ sampler_drop_last=sampler_drop_last,
+ batch_drop_last=batch_drop_last,
+ pin_memory=pin_memory,
+ )
+
+
+def _validate_pipeline_inputs(
+ records: Sequence[NormalizedDatasetRecord],
+ *,
+ contract: PipelineIOContract,
+ source_name: str,
+) -> None:
+ """Validate every normalized input before any condition cache can be reused."""
+ for row_index, record in enumerate(records):
+ try:
+ validate_pipeline_model_input(record.model_input, contract)
+ except (TypeError, ValueError) as exc:
+ error_type = type(exc)
+ raise error_type(
+ f"offline source {source_name!r} row {row_index} violates its pipeline "
+ f"input contract: {exc}"
+ ) from exc
+
+
+def _validate_pipeline_outputs(
+ records: Sequence[NormalizedDatasetRecord],
+ *,
+ contract: PipelineIOContract,
+ source_name: str,
+) -> None:
+ """Validate every supervision candidate before condition preprocessing."""
+ for row_index, record in enumerate(records):
+ supervision = record.supervision
+ if isinstance(supervision, DemonstrationSupervision):
+ candidates = (("target", supervision.target.media),)
+ elif isinstance(supervision, PreferenceSupervision):
+ candidates = (
+ ("chosen", supervision.chosen.media),
+ ("rejected", supervision.rejected.media),
+ )
+ else:
+ raise RuntimeError("normalized offline record unexpectedly lacks supervision")
+ for candidate_name, media in candidates:
+ try:
+ validate_pipeline_output_candidate(media, contract)
+ except (TypeError, ValueError) as exc:
+ error_type = type(exc)
+ raise error_type(
+ f"offline source {source_name!r} row {row_index} {candidate_name} "
+ f"violates its pipeline output contract: {exc}"
+ ) from exc
+
+
+def _build_distributed_condition_cache(
+ records: Sequence[NormalizedDatasetRecord],
+ *,
+ source_name: str,
+ split: str,
+ dataset_dir: str,
+ cache_dir: str,
+ preprocess_func: PreprocessCallable,
+ preprocess_kwargs: Mapping[str, Any],
+ preprocessing_batch_size: int,
+ force_reprocess: bool,
+ extra_hash_strs: Sequence[str],
+ preprocess_parallelism: Literal["global", "local"],
+ accelerator: Accelerator,
+) -> HFDataset:
+ """Build one input-only cache through the existing rank-safe orchestrator."""
+ ordered_references = _supports_ordered_references(preprocess_func)
+ effective_batch_size = 1 if ordered_references else preprocessing_batch_size
+ raw_dataset = project_offline_condition_dataset(
+ records,
+ source_name=source_name,
+ ordered_references=ordered_references,
+ )
+ condition_ids = tuple(raw_dataset[OFFLINE_CONDITION_ID_COLUMN])
+ source_hash = compute_offline_condition_source_hash(condition_ids)
+ dataset_builder = _create_or_load_dataset(
+ split=split,
+ accelerator=accelerator,
+ base_kwargs={
+ "dataset_dir": dataset_dir,
+ "cache_dir": cache_dir,
+ "enable_preprocess": True,
+ "force_reprocess": force_reprocess,
+ "preprocessing_batch_size": effective_batch_size,
+ "max_dataset_size": None,
+ "preprocess_func": preprocess_func,
+ "preprocess_kwargs": dict(preprocess_kwargs),
+ "extra_hash_strs": list(extra_hash_strs),
+ "image_dir": dataset_dir,
+ "video_dir": dataset_dir,
+ "audio_dir": dataset_dir,
+ "raw_dataset": raw_dataset,
+ "source_hash_override": source_hash,
+ "passthrough_columns": (OFFLINE_CONDITION_ID_COLUMN,),
+ },
+ enable_distributed=accelerator.num_processes > 1,
+ preprocess_parallelism=preprocess_parallelism,
+ )
+ condition_cache = dataset_builder.processed_dataset
+ if METADATA_COLUMN in condition_cache.column_names:
+ condition_cache = condition_cache.remove_columns(METADATA_COLUMN)
+ return condition_cache
+
+
+def _build_preprocess_kwargs(
+ config: Arguments,
+ preprocess_func: PreprocessCallable,
+ overrides: Optional[Mapping[str, Any]],
+) -> dict[str, Any]:
+ data_args = config.data_args
+ training_args = config.training_args
+ kwargs = filter_kwargs(preprocess_func, **data_args)
+ kwargs.update({"is_train": True, **training_args})
+ kwargs["guidance_scale"] = training_args.get_preprocess_guidance_scale()
+ if overrides is not None:
+ kwargs.update(overrides)
+ return filter_kwargs(preprocess_func, **kwargs)
+
+
+def _build_extra_hash_strs(
+ config: Arguments,
+ extra_hash_strs: Optional[Sequence[str]],
+) -> list[str]:
+ values = [config.model_args.model_type, config.model_args.model_name_or_path]
+ if extra_hash_strs is not None:
+ values.extend(extra_hash_strs)
+ for index, value in enumerate(values):
+ if not isinstance(value, str):
+ raise TypeError(
+ f"offline cache hash component {index} must be str, "
+ f"got {type(value).__name__}: {value!r}"
+ )
+ return values
+
+
+def _validate_training_sources(sources: Sequence[Any]) -> None:
+ if not sources:
+ raise ValueError("offline train data requires at least one enabled data.datasets source")
+ names = set()
+ source_ids = set()
+ for index, source in enumerate(sources):
+ if source.train is None or not source.train.enabled:
+ raise ValueError(f"offline source {index} is not enabled for training")
+ if type(source.train.weight) not in (int, float) or source.train.weight != 1:
+ raise ValueError(
+ f"offline source {source.name!r} requires train.weight=1, "
+ f"got {source.train.weight!r}"
+ )
+ if not isinstance(source.train.split, str) or not source.train.split:
+ raise ValueError(
+ f"offline source {source.name!r} requires a non-empty train.split string"
+ )
+ if not isinstance(source.source_id, int) or isinstance(source.source_id, bool):
+ raise ValueError(
+ f"offline source {source.name!r} requires a resolved integer source_id, "
+ f"got {source.source_id!r}"
+ )
+ if source.source_id < 0:
+ raise ValueError(
+ f"offline source {source.name!r} requires source_id >= 0, got {source.source_id}"
+ )
+ if source.name in names:
+ raise ValueError(f"offline source name {source.name!r} is duplicated")
+ if source.source_id in source_ids:
+ raise ValueError(f"offline source_id {source.source_id} is duplicated")
+ names.add(source.name)
+ source_ids.add(source.source_id)
+
+
+def _slice_records(
+ records: Sequence[NormalizedDatasetRecord],
+ max_dataset_size: Optional[int],
+ *,
+ source_name: str,
+) -> tuple[NormalizedDatasetRecord, ...]:
+ if max_dataset_size is None:
+ return tuple(records)
+ if type(max_dataset_size) is not int or max_dataset_size < 1:
+ raise ValueError(
+ f"offline source {source_name!r} max_dataset_size must be an int >= 1 or None, "
+ f"got {max_dataset_size!r}"
+ )
+ return tuple(records[:max_dataset_size])
+
+
+def _available_decoder_types(
+ media_decoders: Optional[Mapping[MediaType, MediaDecoder]],
+) -> frozenset[MediaType]:
+ available = set(DEFAULT_MEDIA_DECODERS)
+ if media_decoders is not None:
+ for media_type in media_decoders:
+ if media_type not in ("image", "video", "audio"):
+ raise ValueError(f"unsupported offline media decoder type: {media_type!r}")
+ available.add(media_type)
+ return frozenset(available)
+
+
+def _require_decoder_coverage(
+ records: Sequence[NormalizedDatasetRecord],
+ available_decoder_types: frozenset[MediaType],
+ *,
+ source_name: str,
+) -> None:
+ for asset in _iter_supervision_assets(records):
+ if asset.type not in available_decoder_types:
+ raise NotImplementedError(
+ f"offline source {source_name!r} has no decoder for target media type "
+ f"{asset.type!r} at {asset.path!r}"
+ )
+
+
+def _iter_supervision_assets(
+ records: Sequence[NormalizedDatasetRecord],
+) -> Sequence[MediaAsset]:
+ assets = []
+ for record in records:
+ supervision = record.supervision
+ if isinstance(supervision, DemonstrationSupervision):
+ assets.extend(supervision.target.media)
+ elif isinstance(supervision, PreferenceSupervision):
+ assets.extend(supervision.chosen.media)
+ assets.extend(supervision.rejected.media)
+ else:
+ raise RuntimeError("normalized offline record unexpectedly lacks supervision")
+ return tuple(assets)
+
+
+__all__ = ["build_offline_train_dataloader"]
diff --git a/src/flow_factory/data_utils/schema.py b/src/flow_factory/data_utils/schema.py
new file mode 100644
index 000000000..28b9d3670
--- /dev/null
+++ b/src/flow_factory/data_utils/schema.py
@@ -0,0 +1,289 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Strict public schema for version-two dataset records.
+
+This module owns only the JSON boundary and its model-agnostic normalized
+representation. It deliberately does not parse legacy records, decode media,
+or attach loader-owned identities such as source ids and row ids.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from typing import Annotated, Any, Dict, List, Literal, Mapping, Union
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ JsonValue,
+ field_validator,
+)
+
+MediaType = Literal["image", "video", "audio"]
+
+
+class _StrictFrozenModel(BaseModel):
+ """Base configuration shared by every public V2 schema object."""
+
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ strict=True,
+ allow_inf_nan=False,
+ )
+
+
+class _MediaRefBase(_StrictFrozenModel):
+ """Shared path contract for the exact-key media variants."""
+
+ path: str
+
+ @field_validator("path")
+ @classmethod
+ def _validate_path(cls, value: str) -> str:
+ if not value.strip():
+ raise ValueError("media path must be a non-empty string")
+ return value
+
+
+class ImageRef(_MediaRefBase):
+ """Image asset with no rate fields."""
+
+ type: Literal["image"]
+
+
+class VideoRef(_MediaRefBase):
+ """Video asset with an optional source-frame-rate override."""
+
+ type: Literal["video"]
+ fps: float | None = Field(default=None, gt=0)
+
+
+class AudioRef(_MediaRefBase):
+ """Audio asset with an optional source-sample-rate override."""
+
+ type: Literal["audio"]
+ sample_rate: int | None = Field(default=None, gt=0)
+
+
+MediaRef = Annotated[
+ Union[ImageRef, VideoRef, AudioRef],
+ Field(discriminator="type"),
+]
+
+
+class InputSpec(_StrictFrozenModel):
+ """Generation conditions shared by online and offline algorithms."""
+
+ prompt: str
+ negative_prompt: str | None = None
+ media: List[MediaRef] = Field(default_factory=list)
+
+
+class OutputCandidateSpec(_StrictFrozenModel):
+ """One generated-output candidate, potentially containing several modalities."""
+
+ media: List[MediaRef] = Field(min_length=1)
+
+
+class DemonstrationSpec(_StrictFrozenModel):
+ """A single supervised target without naming a training algorithm."""
+
+ type: Literal["demonstration"]
+ target: OutputCandidateSpec
+
+
+class PreferenceSpec(_StrictFrozenModel):
+ """A pairwise preference sharing one record-level input."""
+
+ type: Literal["preference"]
+ chosen: OutputCandidateSpec
+ rejected: OutputCandidateSpec
+
+
+SupervisionSpec = Annotated[
+ Union[DemonstrationSpec, PreferenceSpec],
+ Field(discriminator="type"),
+]
+
+
+class DatasetRecordV2(_StrictFrozenModel):
+ """Strict V2 JSONL record for online, demonstration, or preference data."""
+
+ schema_version: Literal[2]
+ input: InputSpec
+ supervision: SupervisionSpec | None = None
+ metadata: Dict[str, JsonValue] = Field(default_factory=dict)
+
+
+@dataclass(frozen=True, slots=True)
+class MediaAsset:
+ """Normalized media reference with a dataset-root-resolved path."""
+
+ type: MediaType
+ path: str
+ fps: float | None = None
+ sample_rate: int | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class NormalizedModelInput:
+ """Immutable normalized generation conditions preserving media order."""
+
+ prompt: str
+ negative_prompt: str | None
+ media: tuple[MediaAsset, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class NormalizedOutputCandidate:
+ """Immutable ordered output-media candidate."""
+
+ media: tuple[MediaAsset, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class DemonstrationSupervision:
+ """Normalized demonstration supervision."""
+
+ target: NormalizedOutputCandidate
+
+
+@dataclass(frozen=True, slots=True)
+class PreferenceSupervision:
+ """Normalized pairwise preference supervision."""
+
+ chosen: NormalizedOutputCandidate
+ rejected: NormalizedOutputCandidate
+
+
+NormalizedSupervision = Union[DemonstrationSupervision, PreferenceSupervision, None]
+
+
+@dataclass(frozen=True, slots=True)
+class NormalizedDatasetRecord:
+ """Immutable schema facts, excluding identities owned by a dataset loader."""
+
+ model_input: NormalizedModelInput
+ supervision: NormalizedSupervision
+ metadata_json: str
+ schema_version: int = 2
+
+
+def normalize_v2_record(
+ record: Mapping[str, Any] | DatasetRecordV2,
+ *,
+ dataset_dir: str | os.PathLike[str],
+) -> NormalizedDatasetRecord:
+ """Validate and normalize one public V2 record.
+
+ Relative media paths are joined to the expanded dataset directory. Absolute
+ paths remain absolute. Neither path form is required to exist at this schema
+ boundary; decoding owns that check later.
+
+ Args:
+ record: Raw JSON-compatible mapping or an already validated V2 record.
+ dataset_dir: Dataset root used for relative media paths.
+
+ Returns:
+ Immutable normalized schema facts with canonical JSON metadata.
+ """
+ parsed = (
+ record if isinstance(record, DatasetRecordV2) else DatasetRecordV2.model_validate(record)
+ )
+ normalized_input = NormalizedModelInput(
+ prompt=parsed.input.prompt,
+ negative_prompt=parsed.input.negative_prompt,
+ media=tuple(_normalize_media(media, dataset_dir) for media in parsed.input.media),
+ )
+
+ supervision: NormalizedSupervision
+ if parsed.supervision is None:
+ supervision = None
+ elif isinstance(parsed.supervision, DemonstrationSpec):
+ supervision = DemonstrationSupervision(
+ target=_normalize_candidate(parsed.supervision.target, dataset_dir)
+ )
+ else:
+ supervision = PreferenceSupervision(
+ chosen=_normalize_candidate(parsed.supervision.chosen, dataset_dir),
+ rejected=_normalize_candidate(parsed.supervision.rejected, dataset_dir),
+ )
+
+ metadata_json = json.dumps(
+ parsed.metadata,
+ ensure_ascii=False,
+ allow_nan=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ return NormalizedDatasetRecord(
+ model_input=normalized_input,
+ supervision=supervision,
+ metadata_json=metadata_json,
+ )
+
+
+def _normalize_candidate(
+ candidate: OutputCandidateSpec,
+ dataset_dir: str | os.PathLike[str],
+) -> NormalizedOutputCandidate:
+ return NormalizedOutputCandidate(
+ media=tuple(_normalize_media(media, dataset_dir) for media in candidate.media)
+ )
+
+
+def _normalize_media(
+ media: MediaRef,
+ dataset_dir: str | os.PathLike[str],
+) -> MediaAsset:
+ expanded_path = os.path.expanduser(media.path)
+ if os.path.isabs(expanded_path):
+ path = os.path.normpath(expanded_path)
+ else:
+ expanded_dataset_dir = os.path.expanduser(os.fspath(dataset_dir))
+ path = os.path.normpath(os.path.join(expanded_dataset_dir, expanded_path))
+ return MediaAsset(
+ type=media.type,
+ path=path,
+ fps=media.fps if isinstance(media, VideoRef) else None,
+ sample_rate=media.sample_rate if isinstance(media, AudioRef) else None,
+ )
+
+
+__all__ = [
+ "DatasetRecordV2",
+ "AudioRef",
+ "DemonstrationSpec",
+ "DemonstrationSupervision",
+ "ImageRef",
+ "InputSpec",
+ "MediaAsset",
+ "MediaRef",
+ "MediaType",
+ "NormalizedDatasetRecord",
+ "NormalizedModelInput",
+ "NormalizedOutputCandidate",
+ "NormalizedSupervision",
+ "OutputCandidateSpec",
+ "PreferenceSpec",
+ "PreferenceSupervision",
+ "SupervisionSpec",
+ "VideoRef",
+ "normalize_v2_record",
+]
diff --git a/tests/data_utils/test_offline_condition_cache.py b/tests/data_utils/test_offline_condition_cache.py
new file mode 100644
index 000000000..d2b270716
--- /dev/null
+++ b/tests/data_utils/test_offline_condition_cache.py
@@ -0,0 +1,409 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import gc
+import json
+import weakref
+from pathlib import Path
+from typing import Any, Dict, List
+
+import numpy as np
+import pytest
+import torch
+from datasets import Dataset as HFDataset
+from PIL import Image
+
+from flow_factory.data_utils.dataset import GeneralDataset
+from flow_factory.data_utils.offline_condition_cache import (
+ build_offline_condition_cache,
+ compute_offline_condition_source_hash,
+ project_offline_condition_dataset,
+)
+from flow_factory.data_utils.offline_dataset import (
+ OFFLINE_CONDITION_ID_COLUMN,
+ compute_offline_condition_id,
+)
+from flow_factory.data_utils.schema import NormalizedDatasetRecord, normalize_v2_record
+
+
+class CountingPreprocessor:
+ def __init__(self) -> None:
+ self.calls = 0
+ self.received_kwargs: Dict[str, Any] = {}
+
+ def preprocess(self, prompt: List[str], **kwargs: Any) -> Dict[str, torch.Tensor]:
+ self.calls += 1
+ self.received_kwargs = kwargs
+ return {"prompt_embeds": torch.ones(len(prompt), 2)}
+
+
+class GroupedPreprocessor:
+ def __init__(self) -> None:
+ self.images: Any = None
+ self.videos: Any = None
+ self.audios: Any = None
+
+ def preprocess(
+ self,
+ prompt: List[str],
+ images: Any = None,
+ videos: Any = None,
+ audios: Any = None,
+ ) -> Dict[str, torch.Tensor]:
+ self.images = images
+ self.videos = videos
+ self.audios = audios
+ return {"encoded": torch.ones(len(prompt), 1)}
+
+
+class OrderedPreprocessor:
+ supports_ordered_references = True
+
+ def __init__(self) -> None:
+ self.references: Any = None
+
+ def preprocess(
+ self,
+ prompt: List[str],
+ references: List[List[Dict[str, Any]]],
+ ) -> Dict[str, torch.Tensor]:
+ self.references = references
+ return {"encoded": torch.ones(len(prompt), 1)}
+
+
+class CollisionPreprocessor:
+ def preprocess(self, prompt: List[str]) -> Dict[str, List[str]]:
+ return {OFFLINE_CONDITION_ID_COLUMN: ["adapter-owned"] * len(prompt)}
+
+
+def _demonstration_record(
+ dataset_dir: Path,
+ *,
+ input_media: List[Dict[str, Any]] | None = None,
+ target_path: str = "target.png",
+ metadata: Dict[str, Any] | None = None,
+ negative_prompt: str | None = None,
+) -> NormalizedDatasetRecord:
+ return normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {
+ "prompt": "condition prompt",
+ "negative_prompt": negative_prompt,
+ "media": input_media or [],
+ },
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": target_path}]},
+ },
+ "metadata": metadata or {},
+ },
+ dataset_dir=dataset_dir,
+ )
+
+
+def _preference_record(dataset_dir: Path) -> NormalizedDatasetRecord:
+ return normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "preference condition", "media": []},
+ "supervision": {
+ "type": "preference",
+ "chosen": {"media": [{"type": "image", "path": "chosen-private.png"}]},
+ "rejected": {"media": [{"type": "image", "path": "rejected-private.png"}]},
+ },
+ "metadata": {"annotator": "private"},
+ },
+ dataset_dir=dataset_dir,
+ )
+
+
+def test_target_and_metadata_changes_reuse_the_input_only_cache(tmp_path: Path) -> None:
+ first = _demonstration_record(
+ tmp_path,
+ target_path="first-target-does-not-exist.png",
+ metadata={"revision": 1},
+ )
+ second = _demonstration_record(
+ tmp_path,
+ target_path="second-target-does-not-exist.png",
+ metadata={"revision": 2},
+ )
+ preprocessor = CountingPreprocessor()
+
+ first_cache = build_offline_condition_cache(
+ [first],
+ source_name="demo-source",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ preprocessing_batch_size=1,
+ )
+ second_cache = build_offline_condition_cache(
+ [second],
+ source_name="demo-source",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ preprocessing_batch_size=1,
+ )
+
+ expected_condition_id = compute_offline_condition_id(
+ first,
+ index=0,
+ source_name="demo-source",
+ )
+ assert preprocessor.calls == 1
+ assert first_cache._fingerprint == second_cache._fingerprint
+ assert first_cache.cache_files == second_cache.cache_files
+ assert second_cache[0][OFFLINE_CONDITION_ID_COLUMN] == expected_condition_id
+ assert "metadata" not in second_cache.column_names
+ assert "metadata" not in second_cache[0]
+ assert OFFLINE_CONDITION_ID_COLUMN not in preprocessor.received_kwargs
+ assert "first-target-does-not-exist.png" not in repr(second_cache[0])
+ assert "second-target-does-not-exist.png" not in repr(second_cache[0])
+
+
+def test_condition_cache_does_not_retain_the_bound_preprocessor(tmp_path: Path) -> None:
+ preprocessor = CountingPreprocessor()
+ preprocessor_ref = weakref.ref(preprocessor)
+
+ condition_cache = build_offline_condition_cache(
+ [_demonstration_record(tmp_path)],
+ source_name="lifecycle",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ preprocessing_batch_size=1,
+ )
+ del preprocessor
+ gc.collect()
+
+ assert isinstance(condition_cache, HFDataset)
+ assert not hasattr(condition_cache, "_preprocess_func")
+ assert preprocessor_ref() is None
+
+
+def test_projection_contains_only_input_and_identity_columns(tmp_path: Path) -> None:
+ record = _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "reference.png"}],
+ target_path="private-target.png",
+ metadata={"private": "metadata"},
+ negative_prompt="bad quality",
+ )
+
+ projected = project_offline_condition_dataset(
+ [record],
+ source_name="source",
+ ordered_references=False,
+ )
+
+ assert set(projected.column_names) == {
+ "prompt",
+ "negative_prompt",
+ "images",
+ OFFLINE_CONDITION_ID_COLUMN,
+ }
+ assert projected[0]["images"] == [str(tmp_path / "reference.png")]
+ assert "private-target.png" not in repr(projected[0])
+
+
+def test_preference_arms_never_enter_the_condition_projection(tmp_path: Path) -> None:
+ projected = project_offline_condition_dataset(
+ [_preference_record(tmp_path)],
+ source_name="preference",
+ ordered_references=False,
+ )
+
+ assert set(projected.column_names) == {"prompt", OFFLINE_CONDITION_ID_COLUMN}
+ assert "chosen-private.png" not in repr(projected[0])
+ assert "rejected-private.png" not in repr(projected[0])
+ assert "annotator" not in repr(projected[0])
+
+
+def test_condition_source_hash_is_order_sensitive() -> None:
+ assert compute_offline_condition_source_hash(["first", "second"]) != (
+ compute_offline_condition_source_hash(["second", "first"])
+ )
+
+
+def test_grouped_projection_preserves_per_modality_order_and_rate_overrides(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ for name, color in (("first.png", (255, 0, 0)), ("second.png", (0, 255, 0))):
+ Image.new("RGB", (2, 2), color=color).save(tmp_path / name)
+ video_calls: List[tuple[str, float | None]] = []
+ audio_calls: List[tuple[str, int | None]] = []
+
+ def fake_load_video(path: str, fps: float | None = None) -> List[Image.Image]:
+ video_calls.append((path, fps))
+ value = 10 if path.endswith("first.mp4") else 20
+ return [Image.new("RGB", (2, 2), color=(value, value, value))]
+
+ def fake_load_audio(path: str, sample_rate: int | None = None) -> torch.Tensor:
+ audio_calls.append((path, sample_rate))
+ return torch.ones(1, 4)
+
+ monkeypatch.setattr("flow_factory.data_utils.dataset.load_video_frames", fake_load_video)
+ monkeypatch.setattr("flow_factory.data_utils.dataset.load_audio", fake_load_audio)
+ record = _demonstration_record(
+ tmp_path,
+ input_media=[
+ {"type": "image", "path": "first.png"},
+ {"type": "video", "path": "first.mp4", "fps": 12.5},
+ {"type": "audio", "path": "voice.wav", "sample_rate": 16000},
+ {"type": "image", "path": "second.png"},
+ {"type": "video", "path": "second.mp4"},
+ ],
+ )
+ preprocessor = GroupedPreprocessor()
+
+ build_offline_condition_cache(
+ [record],
+ source_name="grouped",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ force_reprocess=True,
+ preprocessing_batch_size=1,
+ )
+
+ assert [image.getpixel((0, 0)) for image in preprocessor.images[0]] == [
+ (255, 0, 0),
+ (0, 255, 0),
+ ]
+ assert video_calls == [
+ (str(tmp_path / "first.mp4"), 12.5),
+ (str(tmp_path / "second.mp4"), None),
+ ]
+ assert audio_calls == [(str(tmp_path / "voice.wav"), 16000)]
+ assert len(preprocessor.videos[0]) == 2
+ assert len(preprocessor.audios[0]) == 1
+
+
+def test_ordered_heterogeneous_references_cross_arrow_as_canonical_json(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "image.png")
+ monkeypatch.setattr(
+ "flow_factory.data_utils.dataset._decode_ordered_video",
+ lambda path: (np.zeros((1, 2, 2, 3), dtype=np.uint8), 30.0, None, None),
+ )
+ monkeypatch.setattr(
+ "flow_factory.data_utils.dataset._decode_ordered_audio",
+ lambda path: (torch.zeros(1, 8), 22050),
+ )
+ record = _demonstration_record(
+ tmp_path,
+ input_media=[
+ {"type": "image", "path": "image.png"},
+ {"type": "video", "path": "video.mp4", "fps": 24.0},
+ {"type": "audio", "path": "audio.wav", "sample_rate": 44100},
+ ],
+ )
+ projected = project_offline_condition_dataset(
+ [record],
+ source_name="ordered",
+ ordered_references=True,
+ )
+ raw_manifest = projected[0]["references"]
+ raw_references = json.loads(raw_manifest)
+
+ assert isinstance(raw_manifest, str)
+ assert [set(reference) for reference in raw_references] == [
+ {"kind", "path"},
+ {"kind", "path", "fps"},
+ {"kind", "path", "sample_rate"},
+ ]
+ assert [reference["kind"] for reference in raw_references] == [
+ "image",
+ "video",
+ "audio",
+ ]
+
+ preprocessor = OrderedPreprocessor()
+ cache = build_offline_condition_cache(
+ [record],
+ source_name="ordered",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ force_reprocess=True,
+ )
+ loaded = preprocessor.references[0]
+
+ assert set(loaded[0]) == {"kind", "path", "media"}
+ assert set(loaded[1]) == {"kind", "path", "fps", "frames"}
+ assert set(loaded[2]) == {"kind", "path", "sample_rate", "media"}
+ assert cache[0][OFFLINE_CONDITION_ID_COLUMN] == compute_offline_condition_id(
+ record,
+ index=0,
+ source_name="ordered",
+ )
+
+
+def test_preprocess_cannot_overwrite_reserved_condition_identity(tmp_path: Path) -> None:
+ record = _demonstration_record(tmp_path)
+
+ with pytest.raises(ValueError, match="collides with passthrough columns"):
+ build_offline_condition_cache(
+ [record],
+ source_name="collision",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=CollisionPreprocessor().preprocess,
+ force_reprocess=True,
+ preprocessing_batch_size=1,
+ )
+
+
+def test_in_memory_general_dataset_requires_explicit_source_identity(tmp_path: Path) -> None:
+ raw_dataset = HFDataset.from_dict({"prompt": ["hello"]})
+
+ with pytest.raises(ValueError, match="source_hash_override is required"):
+ GeneralDataset(
+ dataset_dir=str(tmp_path),
+ raw_dataset=raw_dataset,
+ enable_preprocess=False,
+ )
+
+
+def test_file_backed_online_dataset_keeps_legacy_loading_and_metadata(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ "datasets.config.HF_DATASETS_CACHE",
+ str(tmp_path / "huggingface-cache"),
+ )
+ (tmp_path / "train.jsonl").write_text(
+ json.dumps({"prompt": "online prompt", "label": 7}) + "\n",
+ encoding="utf-8",
+ )
+ preprocessor = CountingPreprocessor()
+
+ dataset = GeneralDataset(
+ dataset_dir=str(tmp_path),
+ cache_dir=str(tmp_path / "cache"),
+ preprocess_func=preprocessor.preprocess,
+ force_reprocess=True,
+ preprocessing_batch_size=1,
+ )
+
+ assert dataset[0]["prompt"] == "online prompt"
+ assert dataset[0]["metadata"] == {"label": 7}
+ assert preprocessor.calls == 1
diff --git a/tests/data_utils/test_offline_dataset.py b/tests/data_utils/test_offline_dataset.py
new file mode 100644
index 000000000..4b0aa5a91
--- /dev/null
+++ b/tests/data_utils/test_offline_dataset.py
@@ -0,0 +1,880 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import functools
+import json
+import multiprocessing
+import pickle
+from pathlib import Path
+from typing import Any, Dict, List, Sequence
+
+import av
+import numpy as np
+import pytest
+import torch
+from PIL import Image
+
+import flow_factory.data_utils.offline_dataset as offline_dataset_module
+from flow_factory.data_utils.offline_dataset import (
+ OFFLINE_CONDITION_ID_COLUMN,
+ DemonstrationOutput,
+ DemonstrationOutputBatch,
+ OfflineCollator,
+ OfflineDataset,
+ PreferenceOutput,
+ PreferenceOutputBatch,
+ compute_offline_condition_id,
+ compute_offline_record_id,
+ decode_video,
+ load_offline_manifest,
+)
+from flow_factory.data_utils.schema import MediaAsset, NormalizedDatasetRecord, normalize_v2_record
+
+SOURCE_NAME = "test-source"
+SOURCE_ID = 7
+
+
+def _save_image(path: Path, color: tuple[int, int, int]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ Image.new("RGB", (3, 2), color=color).save(path)
+
+
+def _save_video(path: Path, values: Sequence[int], *, fps: int = 24) -> None:
+ """Write a tiny deterministic RGB video for decoder and spawn-worker tests."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with av.open(path, mode="w") as container:
+ stream = container.add_stream("mpeg4", rate=fps)
+ stream.width = 6
+ stream.height = 4
+ stream.pix_fmt = "yuv420p"
+ for value in values:
+ pixels = np.full((stream.height, stream.width, 3), value, dtype=np.uint8)
+ frame = av.VideoFrame.from_ndarray(pixels, format="rgb24")
+ for packet in stream.encode(frame):
+ container.mux(packet)
+ for packet in stream.encode():
+ container.mux(packet)
+
+
+def _demonstration_record(
+ dataset_dir: Path,
+ target_paths: Sequence[str],
+ *,
+ prompt: str = "Draw the target.",
+) -> NormalizedDatasetRecord:
+ return normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": prompt, "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {
+ "media": [
+ {"type": "image", "path": target_path} for target_path in target_paths
+ ]
+ },
+ },
+ "metadata": {"prompt": prompt},
+ },
+ dataset_dir=dataset_dir,
+ )
+
+
+def _preference_record(
+ dataset_dir: Path,
+ chosen_paths: Sequence[str],
+ rejected_paths: Sequence[str],
+) -> NormalizedDatasetRecord:
+ return normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "Choose the better output.", "media": []},
+ "supervision": {
+ "type": "preference",
+ "chosen": {
+ "media": [
+ {"type": "image", "path": chosen_path} for chosen_path in chosen_paths
+ ]
+ },
+ "rejected": {
+ "media": [
+ {"type": "image", "path": rejected_path} for rejected_path in rejected_paths
+ ]
+ },
+ },
+ },
+ dataset_dir=dataset_dir,
+ )
+
+
+def _condition_cache(
+ records: Sequence[NormalizedDatasetRecord],
+ conditions: Sequence[Dict[str, Any]],
+ *,
+ source_name: str = SOURCE_NAME,
+) -> List[Dict[str, Any]]:
+ assert len(records) == len(conditions)
+ return [
+ {
+ **condition,
+ OFFLINE_CONDITION_ID_COLUMN: compute_offline_condition_id(
+ record,
+ index=index,
+ source_name=source_name,
+ ),
+ }
+ for index, (record, condition) in enumerate(zip(records, conditions))
+ ]
+
+
+def _record_ids(
+ records: Sequence[NormalizedDatasetRecord],
+ *,
+ source_name: str = SOURCE_NAME,
+) -> List[str]:
+ return [
+ compute_offline_record_id(record, index=index, source_name=source_name)
+ for index, record in enumerate(records)
+ ]
+
+
+def _custom_video_decoder(asset: MediaAsset) -> Dict[str, str]:
+ return {"decoded_path": asset.path}
+
+
+class _BoundDecoder:
+ def decode(self, asset: MediaAsset) -> str:
+ return asset.path
+
+
+class _CallableDecoder:
+ def __call__(self, asset: MediaAsset) -> str:
+ return asset.path
+
+
+def _spawn_dataset_worker(
+ dataset: OfflineDataset,
+ collator: OfflineCollator,
+ result_queue: Any,
+) -> None:
+ batch = collator([dataset[0]])
+ result_queue.put(
+ (
+ batch.output.target_media[0][0].payload.getpixel((0, 0)),
+ batch.condition["cached_text"],
+ batch.sources,
+ batch.source_ids.tolist(),
+ )
+ )
+
+
+def _spawn_video_dataset_worker(dataset: OfflineDataset, result_queue: Any) -> None:
+ payload = dataset[0].output.target_media[0].payload
+ result_queue.put((payload.shape, payload.dtype.str, payload.flags.c_contiguous))
+
+
+def test_manifest_reader_preserves_order_resolves_paths_and_requires_one_type(
+ tmp_path: Path,
+) -> None:
+ manifest_path = tmp_path / "train.jsonl"
+ rows = [
+ {
+ "schema_version": 2,
+ "input": {"prompt": "first", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "targets/first.png"}]},
+ },
+ },
+ {
+ "schema_version": 2,
+ "input": {"prompt": "second", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "targets/second.png"}]},
+ },
+ },
+ ]
+ manifest_path.write_text(
+ "".join(json.dumps(row) + "\n" for row in rows),
+ encoding="utf-8",
+ )
+
+ records = load_offline_manifest(
+ manifest_path,
+ supervision_type="demonstration",
+ )
+
+ assert [record.model_input.prompt for record in records] == ["first", "second"]
+ assert records[0].supervision.target.media[0].path == str(tmp_path / "targets" / "first.png")
+
+
+@pytest.mark.parametrize(
+ "invalid_line,error_fragment",
+ [
+ ('{"schema_version": 2,', "invalid offline JSONL record"),
+ (
+ json.dumps(
+ {
+ "schema_version": 2,
+ "input": {
+ "prompt": "legacy key",
+ "media": [{"kind": "image", "path": "input.png"}],
+ },
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ },
+ }
+ ),
+ "invalid offline V2 record",
+ ),
+ (" ", "blank line"),
+ ],
+)
+def test_manifest_reader_reports_failing_line_context(
+ tmp_path: Path,
+ invalid_line: str,
+ error_fragment: str,
+) -> None:
+ manifest_path = tmp_path / "train.jsonl"
+ valid = {
+ "schema_version": 2,
+ "input": {"prompt": "valid", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ },
+ }
+ manifest_path.write_text(
+ json.dumps(valid) + "\n" + invalid_line + "\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ load_offline_manifest(manifest_path, supervision_type="demonstration")
+ message = str(exc_info.value)
+ assert error_fragment in message
+ assert "train.jsonl:2" in message
+
+
+def test_manifest_reader_rejects_prompt_only_and_mixed_supervision(tmp_path: Path) -> None:
+ prompt_only = tmp_path / "prompt-only.jsonl"
+ prompt_only.write_text(
+ json.dumps(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "no output", "media": []},
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ with pytest.raises(ValueError, match=r"prompt-only\.jsonl:1 is prompt-only"):
+ load_offline_manifest(prompt_only, supervision_type="demonstration")
+
+ mixed = tmp_path / "mixed.jsonl"
+ preference = {
+ "schema_version": 2,
+ "input": {"prompt": "pair", "media": []},
+ "supervision": {
+ "type": "preference",
+ "chosen": {"media": [{"type": "image", "path": "chosen.png"}]},
+ "rejected": {"media": [{"type": "image", "path": "rejected.png"}]},
+ },
+ }
+ demonstration = {
+ "schema_version": 2,
+ "input": {"prompt": "target", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ },
+ }
+ mixed.write_text(
+ json.dumps(demonstration) + "\n" + json.dumps(preference) + "\n",
+ encoding="utf-8",
+ )
+ with pytest.raises(ValueError, match=r"mixed\.jsonl:2 has 'preference' supervision"):
+ load_offline_manifest(mixed, supervision_type="demonstration")
+
+
+def test_empty_manifest_is_rejected(tmp_path: Path) -> None:
+ manifest_path = tmp_path / "empty.jsonl"
+ manifest_path.write_text("", encoding="utf-8")
+
+ with pytest.raises(ValueError, match="offline JSONL manifest is empty"):
+ load_offline_manifest(manifest_path, supervision_type="demonstration")
+
+
+def test_dataset_requires_normalized_homogeneous_records_and_matching_cache_ids(
+ tmp_path: Path,
+) -> None:
+ target_path = tmp_path / "target.png"
+ _save_image(target_path, (10, 20, 30))
+ demonstration = _demonstration_record(tmp_path, ["target.png"])
+ preference = _preference_record(tmp_path, ["target.png"], ["target.png"])
+ condition = _condition_cache(
+ [demonstration],
+ [{"prompt_embeds": torch.ones(2)}],
+ )
+
+ with pytest.raises(ValueError, match="equal length"):
+ OfflineDataset(
+ [demonstration],
+ [],
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ with pytest.raises(ValueError, match="condition cache identity mismatch at index 0"):
+ OfflineDataset(
+ [demonstration],
+ [
+ {
+ "prompt_embeds": torch.ones(2),
+ OFFLINE_CONDITION_ID_COLUMN: "stale-cache-id",
+ }
+ ],
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ with pytest.raises(ValueError, match="missing reserved identity column"):
+ OfflineDataset(
+ [demonstration],
+ [{"prompt_embeds": torch.ones(2)}],
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ with pytest.raises(ValueError, match="has 'preference' supervision"):
+ OfflineDataset(
+ [preference],
+ _condition_cache([preference], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ with pytest.raises(TypeError, match="normalized V2 records only"):
+ OfflineDataset(
+ [{"raw": True}],
+ condition,
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+
+def test_record_id_distinguishes_duplicate_rows_by_stable_index(tmp_path: Path) -> None:
+ record = _demonstration_record(tmp_path, ["target.png"])
+
+ first = compute_offline_record_id(record, index=0, source_name=SOURCE_NAME)
+ second = compute_offline_record_id(record, index=1, source_name=SOURCE_NAME)
+
+ assert len(first) == 64
+ assert first != second
+ assert first == compute_offline_record_id(record, index=0, source_name=SOURCE_NAME)
+ assert first != compute_offline_record_id(record, index=0, source_name="other-source")
+
+
+def test_dataset_decodes_target_on_every_access_and_strips_reserved_condition_id(
+ tmp_path: Path,
+) -> None:
+ target_path = tmp_path / "target.png"
+ _save_image(target_path, (255, 0, 0))
+ record = _demonstration_record(tmp_path, ["target.png"])
+ condition = {"prompt_embeds": torch.ones(2)}
+ dataset = OfflineDataset(
+ [record],
+ _condition_cache([record], [condition]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+ first = dataset[0]
+ assert tuple(first.condition) == ("prompt_embeds",)
+ assert first.condition["prompt_embeds"] is condition["prompt_embeds"]
+ assert OFFLINE_CONDITION_ID_COLUMN not in first.condition
+ assert isinstance(first.output, DemonstrationOutput)
+ assert first.output.target_media[0].payload.getpixel((0, 0)) == (255, 0, 0)
+
+ _save_image(target_path, (0, 0, 255))
+ second = dataset[0]
+
+ assert second.output.target_media[0].payload.getpixel((0, 0)) == (0, 0, 255)
+ assert first.output.target_media[0].payload.getpixel((0, 0)) == (255, 0, 0)
+
+
+def test_condition_id_is_input_only_while_record_id_tracks_full_provenance(
+ tmp_path: Path,
+) -> None:
+ first = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "same input", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "first.png"}]},
+ },
+ "metadata": {"revision": 1},
+ },
+ dataset_dir=tmp_path,
+ )
+ second = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "same input", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "second.png"}]},
+ },
+ "metadata": {"revision": 2},
+ },
+ dataset_dir=tmp_path,
+ )
+
+ assert compute_offline_condition_id(
+ first, index=0, source_name=SOURCE_NAME
+ ) == compute_offline_condition_id(second, index=0, source_name=SOURCE_NAME)
+ assert compute_offline_record_id(
+ first, index=0, source_name=SOURCE_NAME
+ ) != compute_offline_record_id(second, index=0, source_name=SOURCE_NAME)
+
+
+def test_dataset_snapshots_plain_condition_cache_order(tmp_path: Path) -> None:
+ for name in ("first.png", "second.png"):
+ _save_image(tmp_path / name, (1, 2, 3))
+ records = [
+ _demonstration_record(tmp_path, ["first.png"], prompt="first"),
+ _demonstration_record(tmp_path, ["second.png"], prompt="second"),
+ ]
+ cache = _condition_cache(
+ records,
+ [{"label": "first"}, {"label": "second"}],
+ )
+ dataset = OfflineDataset(
+ records,
+ cache,
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+ cache.reverse()
+
+ assert dataset[0].condition["label"] == "first"
+ assert dataset[1].condition["label"] == "second"
+
+
+def test_dataset_revalidates_embedded_condition_id_when_row_changes(tmp_path: Path) -> None:
+ _save_image(tmp_path / "target.png", (1, 2, 3))
+ record = _demonstration_record(tmp_path, ["target.png"])
+ cache = _condition_cache([record], [{"label": "original"}])
+ dataset = OfflineDataset(
+ [record],
+ cache,
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+ cache[0][OFFLINE_CONDITION_ID_COLUMN] = "mutated"
+
+ with pytest.raises(RuntimeError, match="condition cache identity mismatch at index 0"):
+ dataset[0]
+
+
+def test_dataset_precomputes_ids_and_normalizes_negative_indices(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _save_image(tmp_path / "target.png", (1, 2, 3))
+ record = _demonstration_record(tmp_path, ["target.png"])
+ dataset = OfflineDataset(
+ [record],
+ _condition_cache([record], [{"label": "cached"}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ expected_condition_id = compute_offline_condition_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+ expected_record_id = compute_offline_record_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+ monkeypatch.setattr(
+ offline_dataset_module,
+ "compute_offline_condition_id",
+ lambda *args, **kwargs: pytest.fail("condition id was recomputed"),
+ )
+ monkeypatch.setattr(
+ offline_dataset_module,
+ "compute_offline_record_id",
+ lambda *args, **kwargs: pytest.fail("record id was recomputed"),
+ )
+
+ item = dataset[-1]
+
+ assert item.condition_id == expected_condition_id
+ assert item.record_id == expected_record_id
+ with pytest.raises(IndexError, match="out of range"):
+ dataset[1]
+ with pytest.raises(IndexError, match="out of range"):
+ dataset[-2]
+ with pytest.raises(TypeError, match="must be an integer"):
+ dataset[True]
+
+
+def test_demonstration_collator_stacks_conditions_and_preserves_ragged_media(
+ tmp_path: Path,
+) -> None:
+ for name, color in (
+ ("first.png", (1, 0, 0)),
+ ("second-a.png", (2, 0, 0)),
+ ("second-b.png", (3, 0, 0)),
+ ):
+ _save_image(tmp_path / name, color)
+ records = [
+ _demonstration_record(tmp_path, ["first.png"], prompt="first"),
+ _demonstration_record(
+ tmp_path,
+ ["second-a.png", "second-b.png"],
+ prompt="second",
+ ),
+ ]
+ conditions = [
+ {
+ "prompt_embeds": torch.tensor([1.0, 2.0]),
+ "ragged": torch.ones(1),
+ "label": "first",
+ },
+ {
+ "prompt_embeds": torch.tensor([3.0, 4.0]),
+ "ragged": torch.ones(2),
+ "label": "second",
+ },
+ ]
+ cache = _condition_cache(records, conditions)
+ dataset = OfflineDataset(
+ records,
+ cache,
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+ batch = OfflineCollator("demonstration")([dataset[0], dataset[1]])
+
+ assert torch.equal(
+ batch.condition["prompt_embeds"],
+ torch.tensor([[1.0, 2.0], [3.0, 4.0]]),
+ )
+ assert [tensor.shape for tensor in batch.condition["ragged"]] == [
+ torch.Size([1]),
+ torch.Size([2]),
+ ]
+ assert batch.condition["label"] == ["first", "second"]
+ assert isinstance(batch.output, DemonstrationOutputBatch)
+ assert [len(sample_media) for sample_media in batch.output.target_media] == [1, 2]
+ assert [media.path for media in batch.output.target_media[1]] == [
+ str(tmp_path / "second-a.png"),
+ str(tmp_path / "second-b.png"),
+ ]
+ assert batch.condition_ids == tuple(row[OFFLINE_CONDITION_ID_COLUMN] for row in cache)
+ assert batch.record_ids == tuple(_record_ids(records))
+ assert batch.sources == (SOURCE_NAME, SOURCE_NAME)
+ assert torch.equal(batch.source_ids, torch.tensor([SOURCE_ID, SOURCE_ID]))
+
+
+def test_collator_transports_mixed_source_identity_from_concat_batches(tmp_path: Path) -> None:
+ target_path = tmp_path / "target.png"
+ _save_image(target_path, (1, 2, 3))
+ record = _demonstration_record(tmp_path, ["target.png"])
+ first_source = OfflineDataset(
+ [record],
+ _condition_cache(
+ [record],
+ [{"prompt_embeds": torch.ones(2)}],
+ source_name="first-source",
+ ),
+ source_name="first-source",
+ source_id=2,
+ supervision_type="demonstration",
+ )
+ second_source = OfflineDataset(
+ [record],
+ _condition_cache(
+ [record],
+ [{"prompt_embeds": torch.zeros(2)}],
+ source_name="second-source",
+ ),
+ source_name="second-source",
+ source_id=5,
+ supervision_type="demonstration",
+ )
+
+ batch = OfflineCollator("demonstration")([first_source[0], second_source[0]])
+
+ assert batch.sources == ("first-source", "second-source")
+ assert torch.equal(batch.source_ids, torch.tensor([2, 5], dtype=torch.long))
+ assert batch.record_ids[0] != batch.record_ids[1]
+
+
+def test_preference_dataset_and_collator_keep_chosen_and_rejected_ragged(
+ tmp_path: Path,
+) -> None:
+ for name, color in (
+ ("chosen-a.png", (1, 0, 0)),
+ ("chosen-b.png", (2, 0, 0)),
+ ("rejected.png", (3, 0, 0)),
+ ):
+ _save_image(tmp_path / name, color)
+ record = _preference_record(
+ tmp_path,
+ ["chosen-a.png", "chosen-b.png"],
+ ["rejected.png"],
+ )
+ dataset = OfflineDataset(
+ [record],
+ _condition_cache([record], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="preference",
+ )
+
+ item = dataset[0]
+ batch = OfflineCollator("preference")([item])
+
+ assert isinstance(item.output, PreferenceOutput)
+ assert [media.path for media in item.output.chosen_media] == [
+ str(tmp_path / "chosen-a.png"),
+ str(tmp_path / "chosen-b.png"),
+ ]
+ assert isinstance(batch.output, PreferenceOutputBatch)
+ assert [len(media) for media in batch.output.chosen_media] == [2]
+ assert [len(media) for media in batch.output.rejected_media] == [1]
+
+
+def test_collator_uses_declared_supervision_instead_of_first_item_union(tmp_path: Path) -> None:
+ target_path = tmp_path / "target.png"
+ _save_image(target_path, (1, 2, 3))
+ demonstration = _demonstration_record(tmp_path, ["target.png"])
+ preference = _preference_record(tmp_path, ["target.png"], ["target.png"])
+ demonstration_dataset = OfflineDataset(
+ [demonstration],
+ _condition_cache([demonstration], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ preference_dataset = OfflineDataset(
+ [preference],
+ _condition_cache([preference], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="preference",
+ )
+
+ with pytest.raises(ValueError, match="supervision mismatch at batch index 1"):
+ OfflineCollator("demonstration")([demonstration_dataset[0], preference_dataset[0]])
+
+
+def test_unsupported_audio_output_fails_explicitly(tmp_path: Path) -> None:
+ media: Dict[str, Any] = {
+ "type": "audio",
+ "path": "target.audio",
+ "sample_rate": 16000,
+ }
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "unsupported", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [media]},
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+ with pytest.raises(NotImplementedError, match=r"type 'audio'.*no decoder"):
+ OfflineDataset(
+ [record],
+ _condition_cache([record], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+
+def test_default_video_decoder_returns_diffusers_compatible_cpu_frames(tmp_path: Path) -> None:
+ target_path = tmp_path / "target.mp4"
+ _save_video(target_path, [16, 64, 192])
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "video", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "video", "path": "target.mp4", "fps": 24.0}]},
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+ dataset = OfflineDataset(
+ [record],
+ _condition_cache([record], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+ item = dataset[0]
+ assert isinstance(item.output, DemonstrationOutput)
+ decoded = item.output.target_media[0]
+ assert isinstance(decoded.payload, np.ndarray)
+ assert decoded.payload.shape == (3, 4, 6, 3)
+ assert decoded.payload.dtype == np.uint8
+ assert decoded.payload.flags.c_contiguous
+ assert decoded.fps == 24.0
+ assert pickle.loads(pickle.dumps(decode_video)) is decode_video
+ pickle.dumps(dataset)
+
+
+def test_default_video_decoder_survives_spawn_worker_pickling(tmp_path: Path) -> None:
+ target_path = tmp_path / "spawn.mp4"
+ _save_video(target_path, [12, 24])
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "spawn video", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "video", "path": "spawn.mp4", "fps": 24.0}]},
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+ dataset = OfflineDataset(
+ [record],
+ tuple(_condition_cache([record], [{"cached_text": "encoded"}])),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ context = multiprocessing.get_context("spawn")
+ result_queue = context.Queue()
+ process = context.Process(target=_spawn_video_dataset_worker, args=(dataset, result_queue))
+
+ process.start()
+ process.join(timeout=20)
+
+ assert process.exitcode == 0
+ assert result_queue.get(timeout=5) == ((2, 4, 6, 3), "|u1", True)
+
+
+def test_module_level_media_decoder_can_be_injected_explicitly(tmp_path: Path) -> None:
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": "video", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "video", "path": "target.mp4", "fps": 24.0}]},
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+ dataset = OfflineDataset(
+ [record],
+ _condition_cache([record], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ media_decoders={"video": _custom_video_decoder},
+ )
+
+ item = dataset[0]
+
+ assert isinstance(item.output, DemonstrationOutput)
+ assert item.output.target_media[0].payload == {"decoded_path": str(tmp_path / "target.mp4")}
+ assert item.output.target_media[0].fps == 24.0
+
+
+@pytest.mark.parametrize(
+ "decoder,error_fragment",
+ [
+ (_BoundDecoder().decode, "must be a module-level function"),
+ (_CallableDecoder(), "must be a module-level function"),
+ (
+ functools.partial(_custom_video_decoder),
+ "must be a module-level function",
+ ),
+ (lambda asset: asset.path, "must be defined at module scope"),
+ ],
+)
+def test_decoder_injection_rejects_non_function_or_local_callables(
+ tmp_path: Path,
+ decoder: Any,
+ error_fragment: str,
+) -> None:
+ record = _demonstration_record(tmp_path, ["target.png"])
+
+ with pytest.raises(TypeError, match=error_fragment):
+ OfflineDataset(
+ [record],
+ _condition_cache([record], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ media_decoders={"image": decoder},
+ )
+
+
+def test_dataset_and_collator_are_pickleable_with_spawn_workers(tmp_path: Path) -> None:
+ target_path = tmp_path / "target.png"
+ _save_image(target_path, (7, 8, 9))
+ record = _demonstration_record(tmp_path, ["target.png"])
+ dataset = OfflineDataset(
+ [record],
+ tuple(_condition_cache([record], [{"cached_text": "encoded"}])),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+ context = multiprocessing.get_context("spawn")
+ result_queue = context.Queue()
+ process = context.Process(
+ target=_spawn_dataset_worker,
+ args=(dataset, OfflineCollator("demonstration"), result_queue),
+ )
+
+ process.start()
+ process.join(timeout=20)
+
+ assert process.exitcode == 0
+ assert result_queue.get(timeout=5) == (
+ (7, 8, 9),
+ ["encoded"],
+ (SOURCE_NAME,),
+ [SOURCE_ID],
+ )
diff --git a/tests/data_utils/test_offline_loader.py b/tests/data_utils/test_offline_loader.py
new file mode 100644
index 000000000..0570a0578
--- /dev/null
+++ b/tests/data_utils/test_offline_loader.py
@@ -0,0 +1,502 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, Literal
+from unittest.mock import patch
+
+import pytest
+import torch
+from PIL import Image
+from torch.utils.data import ConcatDataset, DistributedSampler
+
+from flow_factory.data_utils.offline_dataset import (
+ OFFLINE_CONDITION_ID_COLUMN,
+ OfflineDataset,
+ compute_offline_condition_id,
+)
+from flow_factory.data_utils.offline_loader import build_offline_dataloader
+from flow_factory.data_utils.schema import NormalizedDatasetRecord, normalize_v2_record
+
+
+def _offline_dataset(
+ tmp_path: Path,
+ *,
+ source_name: str,
+ source_id: int,
+ size: int,
+ supervision_type: Literal["demonstration", "preference"] = "demonstration",
+) -> OfflineDataset:
+ records = []
+ conditions = []
+ for index in range(size):
+ target_path = tmp_path / source_name / f"target-{index}.png"
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+ Image.new("RGB", (2, 2), color=(source_id, index, 0)).save(target_path)
+ records.append(
+ _record(
+ tmp_path,
+ prompt=f"{source_name}-{index}",
+ target_path=target_path,
+ supervision_type=supervision_type,
+ )
+ )
+ for index, record in enumerate(records):
+ conditions.append(
+ {
+ "encoded": torch.tensor([source_id, index]),
+ OFFLINE_CONDITION_ID_COLUMN: compute_offline_condition_id(
+ record,
+ index=index,
+ source_name=source_name,
+ ),
+ }
+ )
+ return OfflineDataset(
+ records,
+ conditions,
+ source_name=source_name,
+ source_id=source_id,
+ supervision_type=supervision_type,
+ )
+
+
+def _record(
+ dataset_dir: Path,
+ *,
+ prompt: str,
+ target_path: Path,
+ supervision_type: Literal["demonstration", "preference"],
+) -> NormalizedDatasetRecord:
+ if supervision_type == "demonstration":
+ supervision: Dict[str, Any] = {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": str(target_path)}]},
+ }
+ else:
+ supervision = {
+ "type": "preference",
+ "chosen": {"media": [{"type": "image", "path": str(target_path)}]},
+ "rejected": {"media": [{"type": "image", "path": str(target_path)}]},
+ }
+ return normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {"prompt": prompt, "media": []},
+ "supervision": supervision,
+ },
+ dataset_dir=dataset_dir,
+ )
+
+
+def _build(dataset: OfflineDataset, **overrides: Any):
+ kwargs = {
+ "source_weights": [1],
+ "batch_size": 2,
+ "num_replicas": 1,
+ "rank": 0,
+ "gradient_accumulation_steps": 1,
+ "num_workers": 0,
+ "shuffle": False,
+ "seed": 17,
+ "sampler_drop_last": False,
+ "batch_drop_last": False,
+ }
+ kwargs.update(overrides)
+ return build_offline_dataloader(dataset, **kwargs)
+
+
+def test_single_process_still_uses_explicit_official_distributed_sampler(
+ tmp_path: Path,
+) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="single",
+ source_id=0,
+ size=4,
+ )
+
+ loader = _build(dataset)
+
+ assert isinstance(loader.dataset, ConcatDataset)
+ assert loader.dataset.datasets == [dataset]
+ assert isinstance(loader.sampler, DistributedSampler)
+ assert loader.sampler.num_replicas == 1
+ assert loader.sampler.rank == 0
+ assert loader.sampler.shuffle is False
+ assert loader.sampler.seed == 17
+ assert loader.sampler.drop_last is False
+ assert loader.batch_size == 2
+ assert loader.num_workers == 0
+ assert loader.pin_memory is False
+ assert loader.drop_last is False
+ assert len(loader) == 2
+
+ batch = next(iter(loader))
+ assert batch.sources == ("single", "single")
+ assert torch.equal(batch.source_ids, torch.tensor([0, 0], dtype=torch.long))
+ assert torch.equal(batch.condition["encoded"], torch.tensor([[0, 0], [0, 1]]))
+
+
+def test_multiple_sources_are_concatenated_and_may_share_one_batch(tmp_path: Path) -> None:
+ first = _offline_dataset(
+ tmp_path,
+ source_name="first",
+ source_id=2,
+ size=2,
+ )
+ second = _offline_dataset(
+ tmp_path,
+ source_name="second",
+ source_id=5,
+ size=2,
+ )
+
+ loader = build_offline_dataloader(
+ [first, second],
+ source_weights=[1, 1.0],
+ batch_size=3,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ num_workers=0,
+ shuffle=False,
+ seed=9,
+ sampler_drop_last=False,
+ batch_drop_last=False,
+ )
+ batch = next(iter(loader))
+
+ assert isinstance(loader.dataset, ConcatDataset)
+ assert loader.dataset.datasets == [first, second]
+ assert batch.sources == ("first", "first", "second")
+ assert torch.equal(batch.source_ids, torch.tensor([2, 2, 5], dtype=torch.long))
+
+
+def test_distributed_sampler_receives_rank_world_and_drop_last_explicitly(
+ tmp_path: Path,
+) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="distributed",
+ source_id=0,
+ size=8,
+ )
+
+ rank_zero = _build(
+ dataset,
+ num_replicas=2,
+ rank=0,
+ sampler_drop_last=True,
+ batch_drop_last=True,
+ )
+ rank_one = _build(
+ dataset,
+ num_replicas=2,
+ rank=1,
+ sampler_drop_last=True,
+ batch_drop_last=True,
+ )
+
+ assert list(rank_zero.sampler) == [0, 2, 4, 6]
+ assert list(rank_one.sampler) == [1, 3, 5, 7]
+ for rank, loader in enumerate((rank_zero, rank_one)):
+ assert isinstance(loader.sampler, DistributedSampler)
+ assert loader.sampler.num_replicas == 2
+ assert loader.sampler.rank == rank
+ assert loader.sampler.shuffle is False
+ assert loader.sampler.seed == 17
+ assert loader.sampler.drop_last is True
+ assert loader.drop_last is True
+
+
+def test_sampler_and_batch_drop_last_are_independent_for_uneven_geometry(
+ tmp_path: Path,
+) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="independent-tails",
+ source_id=0,
+ size=10,
+ )
+
+ keep_both = _build(
+ dataset,
+ num_replicas=3,
+ rank=0,
+ batch_size=2,
+ sampler_drop_last=False,
+ batch_drop_last=False,
+ )
+ drop_sampler_only = _build(
+ dataset,
+ num_replicas=3,
+ rank=0,
+ batch_size=2,
+ sampler_drop_last=True,
+ batch_drop_last=False,
+ )
+ drop_batch_only = _build(
+ dataset,
+ num_replicas=3,
+ rank=0,
+ batch_size=2,
+ sampler_drop_last=False,
+ batch_drop_last=True,
+ )
+ drop_both = _build(
+ dataset,
+ num_replicas=3,
+ rank=0,
+ batch_size=2,
+ sampler_drop_last=True,
+ batch_drop_last=True,
+ )
+
+ assert len(keep_both.sampler) == 4
+ assert len(drop_sampler_only.sampler) == 3
+ assert len(drop_batch_only.sampler) == 4
+ assert len(drop_both.sampler) == 3
+ assert len(keep_both) == 2
+ assert len(drop_sampler_only) == 2
+ assert len(drop_batch_only) == 2
+ assert len(drop_both) == 1
+ assert keep_both.sampler.drop_last is False and keep_both.drop_last is False
+ assert drop_sampler_only.sampler.drop_last is True and drop_sampler_only.drop_last is False
+ assert drop_batch_only.sampler.drop_last is False and drop_batch_only.drop_last is True
+ assert drop_both.sampler.drop_last is True and drop_both.drop_last is True
+
+
+def test_builder_never_calls_set_epoch(tmp_path: Path) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="epoch-owner",
+ source_id=0,
+ size=4,
+ )
+
+ with patch.object(DistributedSampler, "set_epoch", autospec=True) as set_epoch:
+ _build(dataset)
+
+ set_epoch.assert_not_called()
+
+
+def test_sources_must_share_one_supervision_type(tmp_path: Path) -> None:
+ demonstration = _offline_dataset(
+ tmp_path,
+ source_name="demonstration",
+ source_id=0,
+ size=2,
+ )
+ preference = _offline_dataset(
+ tmp_path,
+ source_name="preference",
+ source_id=1,
+ size=2,
+ supervision_type="preference",
+ )
+
+ with pytest.raises(ValueError, match="must share one supervision_type"):
+ build_offline_dataloader(
+ [demonstration, preference],
+ source_weights=[1, 1],
+ batch_size=2,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ )
+
+
+def test_multiple_sources_require_unique_names_and_ids(tmp_path: Path) -> None:
+ duplicate_name = [
+ _offline_dataset(tmp_path, source_name="duplicate", source_id=0, size=2),
+ _offline_dataset(tmp_path, source_name="duplicate", source_id=1, size=2),
+ ]
+ with pytest.raises(ValueError, match="source_name 'duplicate' is duplicated"):
+ build_offline_dataloader(
+ duplicate_name,
+ source_weights=[1, 1],
+ batch_size=2,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ )
+
+ duplicate_id = [
+ _offline_dataset(tmp_path, source_name="first-id", source_id=3, size=2),
+ _offline_dataset(tmp_path, source_name="second-id", source_id=3, size=2),
+ ]
+ with pytest.raises(ValueError, match="source_id 3 is duplicated"):
+ build_offline_dataloader(
+ duplicate_id,
+ source_weights=[1, 1],
+ batch_size=2,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ )
+
+
+@pytest.mark.parametrize(
+ "weights,error_type,error_fragment",
+ [
+ ([1], ValueError, "one entry per offline source"),
+ ([1, 2], ValueError, "must equal 1"),
+ ([1, True], TypeError, "must be int or float"),
+ ("11", TypeError, "numeric sequence"),
+ ],
+)
+def test_offline_sources_require_explicit_unit_weights(
+ tmp_path: Path,
+ weights: Any,
+ error_type: type[Exception],
+ error_fragment: str,
+) -> None:
+ datasets = [
+ _offline_dataset(tmp_path, source_name="first", source_id=0, size=2),
+ _offline_dataset(tmp_path, source_name="second", source_id=1, size=2),
+ ]
+
+ with pytest.raises(error_type, match=error_fragment):
+ build_offline_dataloader(
+ datasets,
+ source_weights=weights,
+ batch_size=2,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ )
+
+
+@pytest.mark.parametrize(
+ "override,error_type,error_fragment",
+ [
+ ({"batch_size": 0}, ValueError, "batch_size must be >= 1"),
+ ({"batch_size": True}, TypeError, "batch_size must be int"),
+ ({"num_replicas": 0}, ValueError, "num_replicas must be >= 1"),
+ ({"rank": -1}, ValueError, "rank must be >= 0"),
+ ({"num_replicas": 2, "rank": 2}, ValueError, "rank must satisfy"),
+ ({"gradient_accumulation_steps": 0}, ValueError, "must be >= 1"),
+ ({"num_workers": -1}, ValueError, "num_workers must be >= 0"),
+ ({"seed": True}, TypeError, "seed must be int"),
+ ({"shuffle": 1}, TypeError, "shuffle must be bool"),
+ ({"sampler_drop_last": 0}, TypeError, "sampler_drop_last must be bool"),
+ ({"batch_drop_last": 0}, TypeError, "batch_drop_last must be bool"),
+ ({"pin_memory": 0}, TypeError, "pin_memory must be bool"),
+ ],
+)
+def test_builder_rejects_invalid_runtime_geometry(
+ tmp_path: Path,
+ override: Dict[str, Any],
+ error_type: type[Exception],
+ error_fragment: str,
+) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="validation",
+ source_id=0,
+ size=4,
+ )
+
+ with pytest.raises(error_type, match=error_fragment):
+ _build(dataset, **override)
+
+
+def test_builder_rejects_empty_or_non_offline_sources(tmp_path: Path) -> None:
+ with pytest.raises(ValueError, match="at least one OfflineDataset"):
+ build_offline_dataloader(
+ [],
+ source_weights=[],
+ batch_size=1,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ )
+ with pytest.raises(TypeError, match="source 0 must be OfflineDataset"):
+ build_offline_dataloader(
+ [object()],
+ source_weights=[1],
+ batch_size=1,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ )
+
+
+def test_builder_rejects_rank_local_empty_loader(tmp_path: Path) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="too-small",
+ source_id=0,
+ size=1,
+ )
+
+ with pytest.raises(ValueError, match="offline dataloader is empty on this rank"):
+ _build(
+ dataset,
+ batch_size=2,
+ num_replicas=2,
+ rank=0,
+ sampler_drop_last=True,
+ )
+
+
+def test_gradient_accumulation_tail_fails_without_padding_or_implicit_flush(
+ tmp_path: Path,
+) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="accumulation",
+ source_id=0,
+ size=5,
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ _build(
+ dataset,
+ batch_size=2,
+ gradient_accumulation_steps=2,
+ sampler_drop_last=False,
+ batch_drop_last=False,
+ )
+ message = str(exc_info.value)
+ assert "yields 3 batches" in message
+ assert "do not pad" in message
+ assert "implicitly flush" in message
+
+ aligned = _build(
+ dataset,
+ batch_size=2,
+ gradient_accumulation_steps=2,
+ sampler_drop_last=False,
+ batch_drop_last=True,
+ )
+ assert len(aligned) == 2
+
+
+def test_pin_memory_defaults_false_and_requires_explicit_opt_in(tmp_path: Path) -> None:
+ dataset = _offline_dataset(
+ tmp_path,
+ source_name="pin-memory",
+ source_id=0,
+ size=2,
+ )
+
+ default_loader = _build(dataset)
+ pinned_loader = _build(dataset, pin_memory=True)
+
+ assert default_loader.pin_memory is False
+ assert pinned_loader.pin_memory is True
diff --git a/tests/data_utils/test_offline_train_data.py b/tests/data_utils/test_offline_train_data.py
new file mode 100644
index 000000000..bd24e5da4
--- /dev/null
+++ b/tests/data_utils/test_offline_train_data.py
@@ -0,0 +1,653 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import gc
+import json
+import weakref
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, Dict, List
+
+import pytest
+import torch
+from datasets import Dataset as HFDataset
+from PIL import Image
+from torch.utils.data import ConcatDataset, DistributedSampler
+
+import flow_factory.data_utils.offline_train_data as offline_train_data
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaRule,
+ InputMediaSpec,
+ MediaFormat,
+ MediaType,
+ NegativePromptPolicy,
+ OutputMediaSequence,
+ PipelineIOContract,
+ RateRequirement,
+)
+from flow_factory.data_utils.offline_dataset import (
+ OFFLINE_CONDITION_ID_COLUMN,
+ DemonstrationOutput,
+ OfflineDataset,
+ PreferenceOutput,
+)
+from flow_factory.data_utils.offline_train_data import build_offline_train_dataloader
+from flow_factory.hparams.data_args import DataArguments
+from flow_factory.hparams.dataset_args import DatasetArguments, DatasetTrainSpec
+
+_IMAGE_FORMAT = MediaFormat(
+ type=MediaType.IMAGE,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+_TEXT_TO_IMAGE_CONTRACT = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(items=(_IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
+)
+_ORDERED_IMAGE_CONTRACT = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(InputMediaRule(format=_IMAGE_FORMAT, min_count=1, max_count=1),),
+ binding=InputMediaBinding.ORDERED_REFERENCES,
+ order=InputMediaOrder.GLOBAL,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(items=(_IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
+)
+_TEXT_TO_AUDIO_CONTRACT = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(
+ items=(
+ MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.REQUIRED,
+ ),
+ )
+ ),
+ geometry_source=GeometrySource.OUTPUT_MEDIA,
+ batch_capability=BatchCapability.UNIFORM,
+)
+
+
+class _TrainingArguments(dict):
+ def __init__(
+ self,
+ *,
+ per_device_batch_size: int = 1,
+ gradient_accumulation_steps: int = 1,
+ seed: int = 17,
+ guidance_scale: float = 3.0,
+ ) -> None:
+ super().__init__(
+ per_device_batch_size=per_device_batch_size,
+ gradient_accumulation_steps=gradient_accumulation_steps,
+ seed=seed,
+ guidance_scale=guidance_scale,
+ )
+ self.__dict__.update(self)
+
+ def get_preprocess_guidance_scale(self) -> float:
+ return self.guidance_scale
+
+
+class _Accelerator:
+ def __init__(self, *, num_processes: int = 1, process_index: int = 0) -> None:
+ self.num_processes = num_processes
+ self.process_index = process_index
+ self.local_process_index = process_index
+ self.is_main_process = process_index == 0
+ self.is_local_main_process = process_index == 0
+ self.wait_calls = 0
+ self.prepare_calls = 0
+
+ def wait_for_everyone(self) -> None:
+ self.wait_calls += 1
+
+ def prepare(self, *args: Any) -> None:
+ self.prepare_calls += 1
+ raise AssertionError("offline train loader must not call Accelerator.prepare")
+
+
+class _CountingPreprocessor:
+ def __init__(self) -> None:
+ self.calls = 0
+ self.is_train: bool | None = None
+ self.guidance_scale: float | None = None
+
+ def preprocess(
+ self,
+ prompt: List[str],
+ *,
+ is_train: bool,
+ guidance_scale: float,
+ ) -> Dict[str, torch.Tensor]:
+ self.calls += 1
+ self.is_train = is_train
+ self.guidance_scale = guidance_scale
+ return {"prompt_embeds": torch.ones(len(prompt), 2)}
+
+
+class _OrderedPreprocessor:
+ supports_ordered_references = True
+
+ def __init__(self) -> None:
+ self.references: Any = None
+
+ def preprocess(
+ self,
+ prompt: List[str],
+ references: List[List[Dict[str, Any]]],
+ *,
+ is_train: bool,
+ guidance_scale: float,
+ ) -> Dict[str, torch.Tensor]:
+ del is_train, guidance_scale
+ self.references = references
+ return {"prompt_embeds": torch.ones(len(prompt), 2)}
+
+
+def _source(
+ name: str,
+ dataset_dir: Path,
+ source_id: int | None,
+ *,
+ weight: int = 1,
+ max_dataset_size: int | None = None,
+) -> DatasetArguments:
+ return DatasetArguments(
+ name=name,
+ dataset_dir=str(dataset_dir),
+ train=DatasetTrainSpec(
+ split="train",
+ weight=weight,
+ max_dataset_size=max_dataset_size,
+ ),
+ source_id=source_id,
+ )
+
+
+def _config(
+ tmp_path: Path,
+ sources: List[DatasetArguments],
+ *,
+ max_dataset_size: int | None = None,
+ force_reprocess: bool = False,
+ preprocess_parallelism: str = "local",
+ per_device_batch_size: int = 1,
+ gradient_accumulation_steps: int = 1,
+) -> SimpleNamespace:
+ return SimpleNamespace(
+ data_args=DataArguments(
+ datasets=sources,
+ cache_dir=str(tmp_path / "cache"),
+ preprocessing_batch_size=2,
+ dataloader_num_workers=0,
+ enable_preprocess=True,
+ force_reprocess=force_reprocess,
+ max_dataset_size=max_dataset_size,
+ preprocess_parallelism=preprocess_parallelism,
+ ),
+ training_args=_TrainingArguments(
+ per_device_batch_size=per_device_batch_size,
+ gradient_accumulation_steps=gradient_accumulation_steps,
+ ),
+ model_args=SimpleNamespace(
+ model_type="test-model",
+ model_name_or_path="test/model",
+ ),
+ )
+
+
+def _write_image(path: Path, value: int) -> None:
+ Image.new("RGB", (2, 2), color=(value, value, value)).save(path)
+
+
+def _demonstration_row(prompt: str, target_path: str, *, metadata: int = 0) -> Dict[str, Any]:
+ return {
+ "schema_version": 2,
+ "input": {"prompt": prompt, "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": target_path}]},
+ },
+ "metadata": {"revision": metadata},
+ }
+
+
+def _preference_row(prompt: str, chosen_path: str, rejected_path: str) -> Dict[str, Any]:
+ return {
+ "schema_version": 2,
+ "input": {"prompt": prompt, "media": []},
+ "supervision": {
+ "type": "preference",
+ "chosen": {"media": [{"type": "image", "path": chosen_path}]},
+ "rejected": {"media": [{"type": "image", "path": rejected_path}]},
+ },
+ "metadata": {"annotator": "offline"},
+ }
+
+
+def _write_manifest(dataset_dir: Path, rows: List[Dict[str, Any]]) -> None:
+ dataset_dir.mkdir(parents=True, exist_ok=True)
+ (dataset_dir / "train.jsonl").write_text(
+ "".join(json.dumps(row) + "\n" for row in rows),
+ encoding="utf-8",
+ )
+
+
+def _source_datasets(loader: Any) -> tuple[OfflineDataset, ...]:
+ assert isinstance(loader.dataset, ConcatDataset)
+ return tuple(loader.dataset.datasets)
+
+
+def test_builder_returns_detached_input_cache_and_decodes_target_on_demand(
+ tmp_path: Path,
+) -> None:
+ dataset_dir = tmp_path / "demo"
+ dataset_dir.mkdir()
+ _write_image(dataset_dir / "target.png", 10)
+ _write_manifest(dataset_dir, [_demonstration_row("a prompt", "target.png")])
+ preprocessor = _CountingPreprocessor()
+ preprocessor_ref = weakref.ref(preprocessor)
+ accelerator = _Accelerator()
+
+ loader = build_offline_train_dataloader(
+ _config(tmp_path, [_source("demo", dataset_dir, 0)]),
+ accelerator,
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+
+ (dataset,) = _source_datasets(loader)
+ assert isinstance(loader.sampler, DistributedSampler)
+ assert preprocessor.calls == 1
+ assert preprocessor.is_train is True
+ assert preprocessor.guidance_scale == 3.0
+ assert accelerator.prepare_calls == 0
+ assert isinstance(dataset._condition_cache, HFDataset)
+ assert not hasattr(dataset._condition_cache, "_preprocess_func")
+ assert "metadata" not in dataset._condition_cache.column_names
+ assert "target.png" not in repr(dataset._condition_cache[0])
+
+ first = dataset[0]
+ assert isinstance(first.output, DemonstrationOutput)
+ assert first.output.target_media[0].payload.getpixel((0, 0)) == (10, 10, 10)
+ _write_image(dataset_dir / "target.png", 200)
+ assert dataset[0].output.target_media[0].payload.getpixel((0, 0)) == (200, 200, 200)
+
+ del preprocessor
+ gc.collect()
+ assert preprocessor_ref() is None
+
+
+def test_builder_slices_each_source_and_preserves_resolved_source_identity(tmp_path: Path) -> None:
+ first_dir = tmp_path / "first"
+ second_dir = tmp_path / "second"
+ for dataset_dir, offset in ((first_dir, 0), (second_dir, 10)):
+ dataset_dir.mkdir()
+ rows = []
+ for index in range(3):
+ target = f"target-{index}.png"
+ _write_image(dataset_dir / target, offset + index)
+ rows.append(_demonstration_row(f"prompt-{offset + index}", target))
+ _write_manifest(dataset_dir, rows)
+
+ loader = build_offline_train_dataloader(
+ _config(
+ tmp_path,
+ [
+ _source("first", first_dir, 3, max_dataset_size=1),
+ _source("second", second_dir, 7),
+ ],
+ max_dataset_size=2,
+ ),
+ _Accelerator(),
+ _CountingPreprocessor().preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+
+ first, second = _source_datasets(loader)
+ assert (len(first), len(second)) == (1, 2)
+ assert (first.source_name, first.source_id) == ("first", 3)
+ assert (second.source_name, second.source_id) == ("second", 7)
+ assert len(loader.dataset) == 3
+
+
+def test_target_and_metadata_only_manifest_change_reuses_condition_cache(tmp_path: Path) -> None:
+ dataset_dir = tmp_path / "reuse"
+ dataset_dir.mkdir()
+ _write_image(dataset_dir / "first.png", 1)
+ _write_image(dataset_dir / "second.png", 2)
+ source = _source("reuse", dataset_dir, 0)
+ config = _config(tmp_path, [source])
+ preprocessor = _CountingPreprocessor()
+
+ _write_manifest(
+ dataset_dir,
+ [_demonstration_row("stable prompt", "first.png", metadata=1)],
+ )
+ first_loader = build_offline_train_dataloader(
+ config,
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+ _write_manifest(
+ dataset_dir,
+ [_demonstration_row("stable prompt", "second.png", metadata=2)],
+ )
+ second_loader = build_offline_train_dataloader(
+ config,
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+
+ assert preprocessor.calls == 1
+ first_cache = _source_datasets(first_loader)[0]._condition_cache
+ second_dataset = _source_datasets(second_loader)[0]
+ assert first_cache.cache_files == second_dataset._condition_cache.cache_files
+ assert second_dataset[0].output.target_media[0].path.endswith("second.png")
+ assert "second.png" not in repr(second_dataset._condition_cache[0])
+
+
+def test_builder_supports_homogeneous_offline_preference_sources(tmp_path: Path) -> None:
+ dataset_dir = tmp_path / "preference"
+ dataset_dir.mkdir()
+ _write_image(dataset_dir / "chosen.png", 220)
+ _write_image(dataset_dir / "rejected.png", 20)
+ _write_manifest(
+ dataset_dir,
+ [_preference_row("shared condition", "chosen.png", "rejected.png")],
+ )
+
+ loader = build_offline_train_dataloader(
+ _config(tmp_path, [_source("preference", dataset_dir, 0)]),
+ _Accelerator(),
+ _CountingPreprocessor().preprocess,
+ supervision_type="preference",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+
+ (dataset,) = _source_datasets(loader)
+ item = dataset[0]
+ assert isinstance(item.output, PreferenceOutput)
+ assert item.output.chosen_media[0].payload.getpixel((0, 0)) == (220, 220, 220)
+ assert item.output.rejected_media[0].payload.getpixel((0, 0)) == (20, 20, 20)
+ assert "chosen.png" not in repr(dataset._condition_cache[0])
+ assert "rejected.png" not in repr(dataset._condition_cache[0])
+
+
+def test_builder_uses_bridge_ordered_reference_boundary_with_single_row_batches(
+ tmp_path: Path,
+) -> None:
+ dataset_dir = tmp_path / "ordered"
+ dataset_dir.mkdir()
+ _write_image(dataset_dir / "reference.png", 50)
+ _write_image(dataset_dir / "target.png", 100)
+ row = _demonstration_row("ordered condition", "target.png")
+ row["input"]["media"] = [{"type": "image", "path": "reference.png"}]
+ _write_manifest(dataset_dir, [row])
+ preprocessor = _OrderedPreprocessor()
+
+ loader = build_offline_train_dataloader(
+ _config(tmp_path, [_source("ordered", dataset_dir, 0)]),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_ORDERED_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+
+ assert preprocessor.references is not None
+ assert preprocessor.references[0][0]["kind"] == "image"
+ assert "type" not in preprocessor.references[0][0]
+ (dataset,) = _source_datasets(loader)
+ assert dataset[0].model_input.media[0].type == "image"
+
+
+def test_builder_rejects_unsupported_input_media_before_condition_preprocessing(
+ tmp_path: Path,
+) -> None:
+ """Text-to-image adapters cannot silently discard V2 conditioning media."""
+ dataset_dir = tmp_path / "unsupported-input"
+ dataset_dir.mkdir()
+ _write_image(dataset_dir / "reference.png", 25)
+ _write_image(dataset_dir / "target.png", 75)
+ row = _demonstration_row("must use the reference", "target.png")
+ row["input"]["media"] = [{"type": "image", "path": "reference.png"}]
+ _write_manifest(dataset_dir, [row])
+ preprocessor = _CountingPreprocessor()
+
+ with pytest.raises(
+ ValueError,
+ match=r"source 'unsupported-input' row 0.*does not accept input media type 'image'",
+ ):
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("unsupported-input", dataset_dir, 0)]),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ )
+
+ assert preprocessor.calls == 0
+
+
+def test_builder_rejects_preprocessor_binding_drift_before_dataset_io(tmp_path: Path) -> None:
+ """Projection layout must agree with the adapter-owned binding declaration."""
+ with pytest.raises(ValueError, match=r"binding disagrees.*ordered_references.*False"):
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("missing", tmp_path / "missing", 0)]),
+ _Accelerator(),
+ _CountingPreprocessor().preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_ORDERED_IMAGE_CONTRACT,
+ )
+
+
+def test_builder_rejects_non_unit_weight_and_unresolved_source_id_before_io(
+ tmp_path: Path,
+) -> None:
+ missing_dir = tmp_path / "missing"
+ preprocessor = _CountingPreprocessor()
+
+ with pytest.raises(ValueError, match=r"weight=1.*got 2"):
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("weighted", missing_dir, 0, weight=2)]),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ )
+ with pytest.raises(ValueError, match="resolved integer source_id"):
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("unresolved", missing_dir, None)]),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ )
+ assert preprocessor.calls == 0
+
+
+def test_builder_rejects_missing_target_decoder_before_condition_preprocessing(
+ tmp_path: Path,
+) -> None:
+ dataset_dir = tmp_path / "audio"
+ _write_manifest(
+ dataset_dir,
+ [
+ {
+ "schema_version": 2,
+ "input": {"prompt": "audio target", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {
+ "media": [
+ {
+ "type": "audio",
+ "path": "target.wav",
+ "sample_rate": 16000,
+ }
+ ]
+ },
+ },
+ }
+ ],
+ )
+ preprocessor = _CountingPreprocessor()
+
+ with pytest.raises(
+ NotImplementedError,
+ match=r"source 'audio'.*type 'audio'.*target\.wav",
+ ):
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("audio", dataset_dir, 0)]),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_AUDIO_CONTRACT,
+ )
+ assert preprocessor.calls == 0
+
+
+def test_builder_rejects_output_contract_before_condition_preprocessing(
+ tmp_path: Path,
+) -> None:
+ dataset_dir = tmp_path / "wrong-output"
+ _write_manifest(
+ dataset_dir,
+ [
+ {
+ "schema_version": 2,
+ "input": {"prompt": "video target", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {
+ "media": [
+ {
+ "type": "video",
+ "path": "target.mp4",
+ "fps": 24.0,
+ }
+ ]
+ },
+ },
+ }
+ ],
+ )
+ preprocessor = _CountingPreprocessor()
+
+ with pytest.raises(
+ ValueError,
+ match=r"source 'wrong-output' row 0 target.*expected.*type 'image'.*'video'",
+ ):
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("wrong-output", dataset_dir, 0)]),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ )
+
+ assert preprocessor.calls == 0
+
+
+def test_builder_delegates_distributed_condition_cache_to_rank_safe_orchestrator(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ dataset_dir = tmp_path / "distributed"
+ dataset_dir.mkdir()
+ rows = []
+ for index in range(4):
+ target = f"target-{index}.png"
+ _write_image(dataset_dir / target, index)
+ rows.append(_demonstration_row(f"prompt-{index}", target, metadata=index))
+ _write_manifest(dataset_dir, rows)
+ calls: List[Dict[str, Any]] = []
+
+ def fake_create_or_load_dataset(**kwargs: Any) -> SimpleNamespace:
+ calls.append(kwargs)
+ raw_dataset = kwargs["base_kwargs"]["raw_dataset"]
+ return SimpleNamespace(
+ processed_dataset=HFDataset.from_dict(
+ {
+ "prompt_embeds": [[1.0, 1.0] for _ in range(len(raw_dataset))],
+ OFFLINE_CONDITION_ID_COLUMN: raw_dataset[OFFLINE_CONDITION_ID_COLUMN],
+ }
+ )
+ )
+
+ monkeypatch.setattr(
+ offline_train_data,
+ "_create_or_load_dataset",
+ fake_create_or_load_dataset,
+ )
+ accelerator = _Accelerator(num_processes=2, process_index=1)
+
+ loader = build_offline_train_dataloader(
+ _config(
+ tmp_path,
+ [_source("distributed", dataset_dir, 0)],
+ preprocess_parallelism="global",
+ ),
+ accelerator,
+ _CountingPreprocessor().preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_TEXT_TO_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call["accelerator"] is accelerator
+ assert call["enable_distributed"] is True
+ assert call["preprocess_parallelism"] == "global"
+ raw_dataset = call["base_kwargs"]["raw_dataset"]
+ assert set(raw_dataset.column_names) == {"prompt", OFFLINE_CONDITION_ID_COLUMN}
+ assert "target-0.png" not in repr(raw_dataset[0])
+ assert "revision" not in repr(raw_dataset[0])
+ assert isinstance(loader.sampler, DistributedSampler)
+ assert loader.sampler.num_replicas == 2
+ assert loader.sampler.rank == 1
+ assert accelerator.prepare_calls == 0
diff --git a/tests/data_utils/test_schema.py b/tests/data_utils/test_schema.py
new file mode 100644
index 000000000..f10447cfc
--- /dev/null
+++ b/tests/data_utils/test_schema.py
@@ -0,0 +1,304 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from dataclasses import FrozenInstanceError
+from pathlib import Path
+from typing import Any, Dict
+
+import pytest
+from pydantic import ValidationError
+
+from flow_factory.data_utils.schema import (
+ DatasetRecordV2,
+ DemonstrationSupervision,
+ PreferenceSupervision,
+ normalize_v2_record,
+)
+
+
+def _prompt_only_record(**overrides: Any) -> Dict[str, Any]:
+ record: Dict[str, Any] = {
+ "schema_version": 2,
+ "input": {"prompt": "A hill at sunset.", "media": []},
+ }
+ record.update(overrides)
+ return record
+
+
+def test_prompt_only_boundary_is_strict_and_normalized_record_is_frozen(tmp_path: Path) -> None:
+ raw = _prompt_only_record(metadata={"z": [2, 1], "a": {"text": "月亮"}})
+ parsed = DatasetRecordV2.model_validate(raw)
+ normalized = normalize_v2_record(parsed, dataset_dir=tmp_path / "dataset")
+
+ assert parsed.supervision is None
+ assert normalized.model_input.prompt == "A hill at sunset."
+ assert normalized.model_input.media == ()
+ assert normalized.supervision is None
+ assert normalized.metadata_json == '{"a":{"text":"月亮"},"z":[2,1]}'
+
+ with pytest.raises(ValidationError):
+ parsed.schema_version = 1
+ with pytest.raises(FrozenInstanceError):
+ normalized.metadata_json = "{}"
+
+
+def test_demonstration_normalization_preserves_media_order_and_resolves_paths(
+ tmp_path: Path,
+) -> None:
+ dataset_dir = tmp_path / "dataset"
+ absolute_target = tmp_path / "absolute" / "target.png"
+ raw = _prompt_only_record(
+ input={
+ "prompt": "Use the references in order.",
+ "negative_prompt": "blurry",
+ "media": [
+ {"type": "image", "path": "images/first.png"},
+ {"type": "video", "path": "videos/motion.mp4", "fps": 24},
+ {"type": "audio", "path": "audios/voice.wav", "sample_rate": 16000},
+ ],
+ },
+ supervision={
+ "type": "demonstration",
+ "target": {
+ "media": [
+ {"type": "image", "path": str(absolute_target)},
+ ]
+ },
+ },
+ )
+
+ normalized = normalize_v2_record(raw, dataset_dir=dataset_dir)
+
+ assert [media.type for media in normalized.model_input.media] == [
+ "image",
+ "video",
+ "audio",
+ ]
+ assert [media.path for media in normalized.model_input.media] == [
+ str(dataset_dir / "images" / "first.png"),
+ str(dataset_dir / "videos" / "motion.mp4"),
+ str(dataset_dir / "audios" / "voice.wav"),
+ ]
+ assert normalized.model_input.media[1].fps == 24.0
+ assert normalized.model_input.media[2].sample_rate == 16000
+ assert isinstance(normalized.supervision, DemonstrationSupervision)
+ assert normalized.supervision.target.media[0].path == str(absolute_target)
+
+
+def test_preference_normalization_keeps_both_arms_under_one_input(tmp_path: Path) -> None:
+ raw = _prompt_only_record(
+ supervision={
+ "type": "preference",
+ "chosen": {
+ "media": [
+ {"type": "video", "path": "chosen/result.mp4", "fps": 24},
+ {"type": "audio", "path": "chosen/result.mp4"},
+ ]
+ },
+ "rejected": {
+ "media": [
+ {"type": "video", "path": "rejected/result.mp4"},
+ {"type": "audio", "path": "rejected/result.mp4", "sample_rate": 48000},
+ ]
+ },
+ }
+ )
+
+ normalized = normalize_v2_record(raw, dataset_dir=tmp_path / "dataset")
+
+ assert isinstance(normalized.supervision, PreferenceSupervision)
+ assert [media.type for media in normalized.supervision.chosen.media] == ["video", "audio"]
+ assert [media.type for media in normalized.supervision.rejected.media] == ["video", "audio"]
+ assert normalized.supervision.chosen.media[0].path == str(
+ tmp_path / "dataset" / "chosen" / "result.mp4"
+ )
+ assert normalized.supervision.chosen.media[0].fps == 24.0
+ assert normalized.supervision.rejected.media[1].path == str(
+ tmp_path / "dataset" / "rejected" / "result.mp4"
+ )
+ assert normalized.supervision.rejected.media[1].sample_rate == 48000
+
+
+def test_normalization_expands_dataset_root_and_normalizes_absolute_paths(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("HOME", str(tmp_path))
+ absolute_with_parent = str(tmp_path / "absolute" / ".." / "target.png")
+ raw = _prompt_only_record(
+ input={
+ "prompt": "normalize paths",
+ "media": [
+ {"type": "image", "path": "images/input.png"},
+ {"type": "image", "path": absolute_with_parent},
+ ],
+ }
+ )
+
+ normalized = normalize_v2_record(raw, dataset_dir="~/dataset")
+
+ assert normalized.model_input.media[0].path == str(
+ tmp_path / "dataset" / "images" / "input.png"
+ )
+ assert normalized.model_input.media[1].path == str(tmp_path / "target.png")
+
+
+@pytest.mark.parametrize(
+ "media",
+ [
+ {"kind": "image", "path": "image.png"},
+ {"type": "image", "path": "image.png", "unknown": True},
+ ],
+)
+def test_v2_media_rejects_legacy_kind_and_unknown_keys(media: Dict[str, Any]) -> None:
+ raw = _prompt_only_record(input={"prompt": "strict", "media": [media]})
+
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(raw)
+
+
+@pytest.mark.parametrize(
+ "override",
+ [
+ {"unknown": True},
+ {"input": {"prompt": "strict", "media": [], "unknown": True}},
+ {
+ "supervision": {
+ "type": "demonstration",
+ "target": {
+ "media": [{"type": "image", "path": "target.png"}],
+ "unknown": True,
+ },
+ }
+ },
+ ],
+)
+def test_v2_rejects_unknown_keys_at_every_public_level(override: Dict[str, Any]) -> None:
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(_prompt_only_record(**override))
+
+
+@pytest.mark.parametrize("path", ["", " "])
+def test_media_path_must_be_non_empty(path: str) -> None:
+ raw = _prompt_only_record(
+ input={"prompt": "invalid path", "media": [{"type": "image", "path": path}]}
+ )
+
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(raw)
+
+
+@pytest.mark.parametrize(
+ "media",
+ [
+ {"type": "video", "path": "clip.mp4", "fps": 0},
+ {"type": "video", "path": "clip.mp4", "fps": float("inf")},
+ {"type": "video", "path": "clip.mp4", "fps": "24"},
+ {"type": "video", "path": "clip.mp4", "fps": True},
+ {"type": "image", "path": "image.png", "fps": 24},
+ {"type": "image", "path": "image.png", "fps": None},
+ {"type": "audio", "path": "audio.wav", "fps": 24},
+ {"type": "audio", "path": "audio.wav", "fps": None},
+ {"type": "audio", "path": "audio.wav", "sample_rate": 0},
+ {"type": "audio", "path": "audio.wav", "sample_rate": 16000.0},
+ {"type": "audio", "path": "audio.wav", "sample_rate": "16000"},
+ {"type": "audio", "path": "audio.wav", "sample_rate": True},
+ {"type": "image", "path": "image.png", "sample_rate": 16000},
+ {"type": "image", "path": "image.png", "sample_rate": None},
+ {"type": "video", "path": "clip.mp4", "sample_rate": 16000},
+ {"type": "video", "path": "clip.mp4", "sample_rate": None},
+ ],
+)
+def test_media_rates_are_strict_positive_and_type_specific(media: Dict[str, Any]) -> None:
+ raw = _prompt_only_record(input={"prompt": "invalid rate", "media": [media]})
+
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(raw)
+
+
+def test_video_fps_and_audio_sample_rate_are_optional_source_overrides() -> None:
+ raw = _prompt_only_record(
+ input={
+ "prompt": "source rates",
+ "media": [
+ {"type": "video", "path": "clip.mp4"},
+ {"type": "audio", "path": "audio.wav"},
+ ],
+ }
+ )
+
+ parsed = DatasetRecordV2.model_validate(raw)
+
+ assert parsed.input.media[0].fps is None
+ assert parsed.input.media[1].sample_rate is None
+
+
+@pytest.mark.parametrize("schema_version", [1, "2", True])
+def test_schema_version_is_strictly_integer_two(schema_version: Any) -> None:
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(_prompt_only_record(schema_version=schema_version))
+
+
+@pytest.mark.parametrize("supervision_type", ["sft", "offline-dpo", "unknown"])
+def test_supervision_uses_semantic_discriminator(supervision_type: str) -> None:
+ raw = _prompt_only_record(
+ supervision={
+ "type": supervision_type,
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ }
+ )
+
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(raw)
+
+
+@pytest.mark.parametrize(
+ "supervision",
+ [
+ {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ "chosen": {"media": [{"type": "image", "path": "chosen.png"}]},
+ "rejected": {"media": [{"type": "image", "path": "rejected.png"}]},
+ },
+ {
+ "type": "preference",
+ "chosen": {"media": [{"type": "image", "path": "chosen.png"}]},
+ "rejected": {"media": [{"type": "image", "path": "rejected.png"}]},
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ },
+ {
+ "type": "preference",
+ "chosen": {"media": [{"type": "image", "path": "chosen.png"}]},
+ },
+ ],
+)
+def test_supervision_branches_cannot_be_mixed_or_incomplete(
+ supervision: Dict[str, Any],
+) -> None:
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(_prompt_only_record(supervision=supervision))
+
+
+def test_output_candidate_requires_at_least_one_media_item() -> None:
+ raw = _prompt_only_record(supervision={"type": "demonstration", "target": {"media": []}})
+
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(raw)
+
+
+@pytest.mark.parametrize("bad_value", [object(), float("nan"), float("inf")])
+def test_metadata_accepts_only_finite_json_values(bad_value: Any) -> None:
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(_prompt_only_record(metadata={"bad": bad_value}))
From 0479dd095204ff916d8eeb49f2f9f41487efea26 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:15:58 +0800
Subject: [PATCH 08/76] feat(training): add offline flow matching primitives
---
src/flow_factory/models/abc.py | 4 +-
.../models/trajectory_bridge/noising.py | 16 +-
.../trainers/common/flow_matching.py | 320 ++++++++++++++++++
.../trainers/common/offline_batch.py | 139 ++++++++
.../trajectory/test_forward_process_hooks.py | 64 +++-
.../trainers/test_offline_batch_primitives.py | 141 ++++++++
tests/trainers/test_offline_flow_matching.py | 274 +++++++++++++++
7 files changed, 950 insertions(+), 8 deletions(-)
create mode 100644 src/flow_factory/trainers/common/flow_matching.py
create mode 100644 src/flow_factory/trainers/common/offline_batch.py
create mode 100644 tests/trainers/test_offline_batch_primitives.py
create mode 100644 tests/trainers/test_offline_flow_matching.py
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index 8e2d1bab6..be1b1e0e4 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -4042,7 +4042,7 @@ def build_training_component_times(
self,
primary_timesteps: torch.Tensor,
*,
- batch: Optional[StackedSampleBatch] = None,
+ batch: Optional[Mapping[str, Any]] = None,
) -> ComponentTimes:
"""Map one primary scheduler coordinate onto every component's times.
@@ -4053,7 +4053,7 @@ def build_training_component_times(
Args:
primary_timesteps: Primary scheduler coordinates of shape ``(B,)``.
- batch: Optional collated batch supplying per-component geometry.
+ batch: Optional online or offline mapping supplying per-component geometry.
Returns:
Component times whose sigma follows the flow-matching schedule.
diff --git a/src/flow_factory/models/trajectory_bridge/noising.py b/src/flow_factory/models/trajectory_bridge/noising.py
index bcaca463d..032a3f190 100644
--- a/src/flow_factory/models/trajectory_bridge/noising.py
+++ b/src/flow_factory/models/trajectory_bridge/noising.py
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Mapping, Optional
import torch
from diffusers.utils.torch_utils import randn_tensor
@@ -21,7 +21,6 @@
ComponentTimes,
LatentState,
NoisedState,
- StackedSampleBatch,
)
from ...utils.base import to_broadcast_tensor
from ...utils.noise_schedule import flow_match_sigma
@@ -32,7 +31,7 @@ def build_training_component_times(
adapter: Any,
primary_timesteps: torch.Tensor,
*,
- batch: Optional[StackedSampleBatch],
+ batch: Optional[Mapping[str, Any]],
) -> ComponentTimes:
if not isinstance(primary_timesteps, torch.Tensor):
raise TypeError(
@@ -64,7 +63,7 @@ def resolve_replay_projection_times(
adapter: Any,
times: ComponentTimes,
*,
- batch: Optional[StackedSampleBatch],
+ batch: Optional[Mapping[str, Any]],
) -> ComponentTimes:
"""Prefer authoritative replay sigmas and map only legacy timestep-only data.
@@ -177,6 +176,13 @@ def apply_forward_process_noise(
f"expected sigma component order {expected_names} for "
f"apply_forward_process_noise, received {received}"
)
+ direction = adapter.flow_velocity_direction
+ if direction not in ("noise", "data"):
+ raise ValueError(
+ "expected flow_velocity_direction to be 'noise' or 'data' for "
+ f"apply_forward_process_noise, received {direction!r}"
+ )
+ velocity_sign = 1.0 if direction == "noise" else -1.0
primary_name = expected_names[0]
primary_clean = clean_state.components[primary_name]
if primary_clean.ndim < 2:
@@ -218,7 +224,7 @@ def apply_forward_process_noise(
)
sigma = to_broadcast_tensor(sigma, clean_latents)
component_noised = (1 - sigma) * clean_latents + sigma * component_noise
- component_target = component_noise - clean_latents
+ component_target = velocity_sign * (component_noise - clean_latents)
if clean_state.active_masks is not None:
# The draw above already consumed the full-shape RNG stream; masking only
# decides which elements move, so inactive conditioning stays clean and
diff --git a/src/flow_factory/trainers/common/flow_matching.py b/src/flow_factory/trainers/common/flow_matching.py
new file mode 100644
index 000000000..f20150915
--- /dev/null
+++ b/src/flow_factory/trainers/common/flow_matching.py
@@ -0,0 +1,320 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Model-agnostic flow-matching primitives for finite offline objectives."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, Optional, Tuple, Union
+
+import torch
+
+from ...models.output_state import EncodedOutputState
+from ...samples import ComponentTimes, LatentState, NoisedState
+from ...utils.noise_schedule import TimeSampler
+
+
+def sample_offline_timesteps(
+ training_args: Any,
+ *,
+ batch_size: int,
+ device: Union[torch.device, str],
+ generator: Optional[torch.Generator] = None,
+) -> torch.Tensor:
+ """Draw one independent scheduler coordinate per loss term and sample.
+
+ ``num_train_timesteps`` is the number of Monte Carlo terms averaged inside
+ one dataloader microstep. It does not alter gradient accumulation.
+ """
+ _require_positive_int(batch_size, "batch_size")
+ num_timesteps = getattr(training_args, "num_train_timesteps", None)
+ _require_positive_int(num_timesteps, "training_args.num_train_timesteps")
+ scheme = getattr(training_args, "weighting_scheme", None)
+ common = {
+ "batch_size": batch_size,
+ "num_timesteps": num_timesteps,
+ "timestep_range": getattr(training_args, "timestep_range", None),
+ "time_shift": getattr(training_args, "time_shift", None),
+ "device": torch.device(device),
+ "generator": generator,
+ }
+ if scheme == "logit_normal":
+ return TimeSampler.independent_logit_normal_shifted(
+ **common,
+ logit_mean=getattr(training_args, "logit_mean", None),
+ logit_std=getattr(training_args, "logit_std", None),
+ )
+ if scheme == "uniform":
+ return TimeSampler.independent_uniform(**common)
+ raise ValueError(
+ "offline weighting_scheme must be 'logit_normal' or 'uniform', " f"received {scheme!r}"
+ )
+
+
+def build_noised_output_state(
+ adapter: Any,
+ clean_state: LatentState,
+ primary_timesteps: torch.Tensor,
+ *,
+ batch: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ noise: Optional[LatentState] = None,
+) -> Tuple[ComponentTimes, NoisedState]:
+ """Map scheduler coordinates and apply either fresh or explicitly shared noise."""
+ if not isinstance(clean_state, LatentState):
+ raise TypeError(
+ "expected clean_state to be LatentState, "
+ f"received {type(clean_state).__name__}: {clean_state!r}"
+ )
+ if not isinstance(primary_timesteps, torch.Tensor):
+ raise TypeError(
+ "expected primary_timesteps to be torch.Tensor, "
+ f"received {type(primary_timesteps).__name__}: {primary_timesteps!r}"
+ )
+ if not isinstance(batch, Mapping):
+ raise TypeError(f"expected batch to be Mapping, received {type(batch).__name__}: {batch!r}")
+ if generator is not None and not isinstance(generator, torch.Generator):
+ raise TypeError(
+ "expected generator to be torch.Generator or None, "
+ f"received {type(generator).__name__}: {generator!r}"
+ )
+ if noise is not None and not isinstance(noise, LatentState):
+ raise TypeError(
+ f"expected noise to be LatentState or None, received {type(noise).__name__}"
+ )
+ if noise is not None and generator is not None:
+ raise ValueError(
+ "generator and explicit noise are mutually exclusive; explicit shared noise "
+ "must not consume another RNG stream"
+ )
+
+ times = adapter.build_training_component_times(primary_timesteps, batch=batch)
+ if not isinstance(times, ComponentTimes):
+ raise TypeError(
+ "adapter.build_training_component_times must return ComponentTimes, "
+ f"received {type(times).__name__}"
+ )
+ if noise is None:
+ noised = adapter.add_forward_process_noise(
+ clean_state,
+ times,
+ generator=generator,
+ )
+ else:
+ noised = adapter.apply_forward_process_noise(clean_state, times, noise)
+ if not isinstance(noised, NoisedState):
+ raise TypeError(
+ "adapter forward-process hook must return NoisedState, "
+ f"received {type(noised).__name__}"
+ )
+ return times, noised
+
+
+def flow_matching_per_sample_loss(
+ adapter: Any,
+ predicted_velocity: LatentState,
+ noised: NoisedState,
+) -> torch.Tensor:
+ """Return fp32 velocity MSE reduced over active latent elements per sample."""
+ if not isinstance(predicted_velocity, LatentState):
+ raise TypeError(
+ "expected predicted_velocity to be LatentState, "
+ f"received {type(predicted_velocity).__name__}"
+ )
+ if not isinstance(noised, NoisedState):
+ raise TypeError(f"expected noised to be NoisedState, received {type(noised).__name__}")
+ target_velocity = noised.target_velocity
+ expected_names = target_velocity.component_names
+ if predicted_velocity.component_names != expected_names:
+ raise ValueError(
+ "predicted and target velocity component order mismatch: "
+ f"expected {expected_names}, received {predicted_velocity.component_names}"
+ )
+
+ squared_errors = {}
+ for name in expected_names:
+ predicted = predicted_velocity.components[name]
+ target = target_velocity.components[name]
+ if predicted.shape != target.shape:
+ raise ValueError(
+ f"velocity shape mismatch for component {name!r}: expected "
+ f"{tuple(target.shape)}, received {tuple(predicted.shape)}"
+ )
+ if predicted.device != target.device:
+ raise ValueError(
+ f"velocity device mismatch for component {name!r}: expected "
+ f"{target.device}, received {predicted.device}"
+ )
+ if not predicted.is_floating_point() or not target.is_floating_point():
+ raise TypeError(
+ f"velocity component {name!r} must use floating tensors, received "
+ f"predicted={predicted.dtype}, target={target.dtype}"
+ )
+ squared_errors[name] = (predicted.float() - target.float()).square()
+
+ reduced = adapter.reduce_latent_values(squared_errors, state=noised.state)
+ if not isinstance(reduced, torch.Tensor):
+ raise TypeError(
+ "adapter.reduce_latent_values must return torch.Tensor, "
+ f"received {type(reduced).__name__}"
+ )
+ batch_size = next(iter(squared_errors.values())).shape[0]
+ if reduced.shape != (batch_size,):
+ raise ValueError(
+ "adapter.reduce_latent_values must return one value per sample with shape "
+ f"({batch_size},), received {tuple(reduced.shape)}"
+ )
+ if reduced.dtype is not torch.float32:
+ raise TypeError(
+ "offline flow-matching reduction must preserve fp32 errors, "
+ f"received {reduced.dtype}"
+ )
+ return reduced
+
+
+def validate_preference_output_states(
+ chosen: EncodedOutputState,
+ rejected: EncodedOutputState,
+) -> None:
+ """Require pairwise arms to support the same forward process and reduction."""
+ for name, value in (("chosen", chosen), ("rejected", rejected)):
+ if not isinstance(value, EncodedOutputState):
+ raise TypeError(
+ f"expected {name} output to be EncodedOutputState, "
+ f"received {type(value).__name__}"
+ )
+ chosen_state = chosen.clean_state
+ rejected_state = rejected.clean_state
+ if chosen_state.component_names != rejected_state.component_names:
+ raise ValueError(
+ "preference arm component order mismatch: "
+ f"chosen={chosen_state.component_names}, rejected={rejected_state.component_names}"
+ )
+ for name in chosen_state.component_names:
+ chosen_component = chosen_state.components[name]
+ rejected_component = rejected_state.components[name]
+ if chosen_component.shape != rejected_component.shape:
+ raise ValueError(
+ f"preference arm shape mismatch for component {name!r}: "
+ f"chosen={tuple(chosen_component.shape)}, "
+ f"rejected={tuple(rejected_component.shape)}"
+ )
+ if chosen_component.dtype != rejected_component.dtype:
+ raise TypeError(
+ f"preference arm dtype mismatch for component {name!r}: "
+ f"chosen={chosen_component.dtype}, rejected={rejected_component.dtype}"
+ )
+ if chosen_component.device != rejected_component.device:
+ raise ValueError(
+ f"preference arm device mismatch for component {name!r}: "
+ f"chosen={chosen_component.device}, rejected={rejected_component.device}"
+ )
+ if chosen.geometry_signatures != rejected.geometry_signatures:
+ raise ValueError(
+ "preference arms must use identical geometry signatures before shared-noise "
+ f"training, received chosen={chosen.geometry_signatures}, "
+ f"rejected={rejected.geometry_signatures}"
+ )
+ _validate_matching_masks(chosen_state, rejected_state)
+ _validate_matching_context_structure(
+ chosen.forward_context,
+ rejected.forward_context,
+ identifier="forward_context",
+ )
+
+
+def _validate_matching_masks(chosen: LatentState, rejected: LatentState) -> None:
+ if (chosen.active_masks is None) != (rejected.active_masks is None):
+ raise ValueError("preference arms must either both define active masks or both omit them")
+ if chosen.active_masks is None:
+ return
+ if tuple(chosen.active_masks) != tuple(rejected.active_masks):
+ raise ValueError("preference arm active-mask component order mismatch")
+ for name in chosen.component_names:
+ chosen_mask = chosen.active_masks[name]
+ rejected_mask = rejected.active_masks[name]
+ if (
+ chosen_mask.shape != rejected_mask.shape
+ or chosen_mask.device != rejected_mask.device
+ or not torch.equal(chosen_mask, rejected_mask)
+ ):
+ raise ValueError(
+ f"preference arms require identical active masks for component {name!r}"
+ )
+
+
+def _validate_matching_context_structure(chosen: Any, rejected: Any, *, identifier: str) -> None:
+ if isinstance(chosen, torch.Tensor) or isinstance(rejected, torch.Tensor):
+ if not isinstance(chosen, torch.Tensor) or not isinstance(rejected, torch.Tensor):
+ raise TypeError(f"preference arm {identifier} tensor structure mismatch")
+ if (
+ chosen.shape != rejected.shape
+ or chosen.dtype != rejected.dtype
+ or chosen.device != rejected.device
+ ):
+ raise ValueError(
+ f"preference arm {identifier} tensor metadata mismatch: "
+ f"chosen=({tuple(chosen.shape)}, {chosen.dtype}, {chosen.device}), "
+ f"rejected=({tuple(rejected.shape)}, {rejected.dtype}, {rejected.device})"
+ )
+ return
+ if isinstance(chosen, Mapping) or isinstance(rejected, Mapping):
+ if not isinstance(chosen, Mapping) or not isinstance(rejected, Mapping):
+ raise TypeError(f"preference arm {identifier} mapping structure mismatch")
+ if tuple(chosen) != tuple(rejected):
+ raise ValueError(
+ f"preference arm {identifier} key/order mismatch: "
+ f"chosen={tuple(chosen)}, rejected={tuple(rejected)}"
+ )
+ for key in chosen:
+ _validate_matching_context_structure(
+ chosen[key],
+ rejected[key],
+ identifier=f"{identifier}[{key!r}]",
+ )
+ return
+ if isinstance(chosen, (list, tuple)) or isinstance(rejected, (list, tuple)):
+ if type(chosen) is not type(rejected) or len(chosen) != len(rejected):
+ raise TypeError(f"preference arm {identifier} sequence structure mismatch")
+ for index, (chosen_item, rejected_item) in enumerate(zip(chosen, rejected)):
+ _validate_matching_context_structure(
+ chosen_item,
+ rejected_item,
+ identifier=f"{identifier}[{index}]",
+ )
+ return
+ if type(chosen) is not type(rejected):
+ raise TypeError(
+ f"preference arm {identifier} scalar type mismatch: "
+ f"chosen={type(chosen).__name__}, rejected={type(rejected).__name__}"
+ )
+
+
+def _require_positive_int(value: object, identifier: str) -> None:
+ if type(value) is not int:
+ raise TypeError(
+ f"expected positive int for {identifier}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+ if value < 1:
+ raise ValueError(f"expected {identifier} >= 1, received {value}")
+
+
+__all__ = [
+ "build_noised_output_state",
+ "flow_matching_per_sample_loss",
+ "sample_offline_timesteps",
+ "validate_preference_output_states",
+]
diff --git a/src/flow_factory/trainers/common/offline_batch.py b/src/flow_factory/trainers/common/offline_batch.py
new file mode 100644
index 000000000..4b25d3e2d
--- /dev/null
+++ b/src/flow_factory/trainers/common/offline_batch.py
@@ -0,0 +1,139 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Offline batch utilities that keep dataset, model, and algorithm fields separate."""
+
+from collections.abc import Mapping, Set
+from dataclasses import is_dataclass
+from typing import Any, Dict, Union
+
+import torch
+
+from ...contracts import NON_MODEL_CONDITION_KEYS
+
+
+def move_condition_to_device(
+ condition: Mapping[str, Any],
+ device: Union[torch.device, str],
+ *,
+ non_blocking: bool = False,
+) -> Dict[str, Any]:
+ """Copy a cached condition tree while moving only its tensor leaves.
+
+ Args:
+ condition: Input-only model condition mapping from an offline batch.
+ device: Destination device for tensor leaves.
+ non_blocking: Forwarded to :meth:`torch.Tensor.to`.
+
+ Returns:
+ A new mutable mapping whose container tree is detached from the cache row.
+ """
+ _require_string_key_mapping(condition, "offline condition")
+ if not isinstance(non_blocking, bool):
+ raise TypeError(f"non_blocking must be a bool, got {non_blocking!r}")
+ target_device = torch.device(device)
+ return {
+ key: _move_condition_value(value, target_device, non_blocking=non_blocking)
+ for key, value in condition.items()
+ }
+
+
+def bind_output_forward_context(
+ condition: Mapping[str, Any],
+ forward_context: Mapping[str, Any],
+) -> Dict[str, Any]:
+ """Bind input conditions and output-derived model fields without mutation.
+
+ Args:
+ condition: Cached input-only model fields.
+ forward_context: Adapter-owned fields derived from output geometry.
+
+ Returns:
+ A new model-conditioning mapping.
+
+ Raises:
+ TypeError: If either argument is not a string-keyed mapping.
+ ValueError: If either side contains non-model fields or both own one key.
+ """
+ _require_string_key_mapping(condition, "offline condition")
+ _require_string_key_mapping(forward_context, "output forward context")
+ _reject_non_model_keys(condition, "offline condition")
+ _reject_non_model_keys(forward_context, "output forward context")
+ collisions = tuple(sorted(set(condition).intersection(forward_context)))
+ if collisions:
+ raise ValueError(
+ "output forward context collides with cached condition keys "
+ f"{collisions}; input and output fields must have one owner"
+ )
+ return {**condition, **forward_context}
+
+
+def _move_condition_value(
+ value: Any,
+ device: torch.device,
+ *,
+ non_blocking: bool,
+) -> Any:
+ if isinstance(value, torch.Tensor):
+ return value.to(device=device, non_blocking=non_blocking)
+ if isinstance(value, Mapping):
+ _require_string_key_mapping(value, "nested offline condition")
+ return {
+ key: _move_condition_value(item, device, non_blocking=non_blocking)
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [_move_condition_value(item, device, non_blocking=non_blocking) for item in value]
+ if type(value) is tuple:
+ return tuple(
+ _move_condition_value(item, device, non_blocking=non_blocking) for item in value
+ )
+ if isinstance(value, tuple) and hasattr(value, "_fields"):
+ moved = (_move_condition_value(item, device, non_blocking=non_blocking) for item in value)
+ return type(value)(*moved)
+ if isinstance(value, torch.Size):
+ return torch.Size(value)
+ if isinstance(value, Set) or is_dataclass(value):
+ raise TypeError(
+ "offline condition trees support Mapping, list, tuple, namedtuple, and tensor "
+ f"containers only; received unsupported {type(value).__name__}"
+ )
+ return value
+
+
+def _require_string_key_mapping(value: Any, identifier: str) -> None:
+ if not isinstance(value, Mapping):
+ raise TypeError(
+ f"expected Mapping[str, Any] for {identifier}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+ non_string_keys = tuple(key for key in value if not isinstance(key, str))
+ if non_string_keys:
+ raise TypeError(f"expected string keys for {identifier}, received {non_string_keys!r}")
+ empty_keys = tuple(key for key in value if not key)
+ if empty_keys:
+ raise ValueError(
+ f"expected non-empty string keys for {identifier}, received {empty_keys!r}"
+ )
+
+
+def _reject_non_model_keys(value: Mapping[str, Any], identifier: str) -> None:
+ rejected = tuple(sorted(set(value).intersection(NON_MODEL_CONDITION_KEYS)))
+ if rejected:
+ raise ValueError(
+ f"{identifier} contains fields that cannot enter model forward: {rejected}"
+ )
+
+
+__all__ = ["bind_output_forward_context", "move_condition_to_device"]
diff --git a/tests/models/trajectory/test_forward_process_hooks.py b/tests/models/trajectory/test_forward_process_hooks.py
index d3eb61d62..512901418 100644
--- a/tests/models/trajectory/test_forward_process_hooks.py
+++ b/tests/models/trajectory/test_forward_process_hooks.py
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from types import SimpleNamespace
+from types import MappingProxyType, SimpleNamespace
from typing import Any, List
import pytest
@@ -20,6 +20,7 @@
from diffusers.utils.torch_utils import randn_tensor
from flow_factory.models.abc import BaseAdapter
+from flow_factory.models.trajectory_bridge import resolve_replay_projection_times
from flow_factory.samples import BaseSample, ComponentTimes, LatentState
from flow_factory.scheduler import SchedulerGroup, SDESchedulerOutput
from flow_factory.utils.base import to_broadcast_tensor
@@ -168,6 +169,18 @@ def test_default_component_times_consume_no_randomness() -> None:
assert torch.equal(torch.get_rng_state(), state_before)
+def test_default_component_times_accept_a_generic_mapping_batch() -> None:
+ adapter = _adapter()
+ primary = torch.tensor([1000.0, 250.0])
+
+ times = adapter.build_training_component_times(
+ primary,
+ batch=MappingProxyType({"offline": True}),
+ )
+
+ assert torch.equal(times.timestep["latent"], primary)
+
+
def test_default_component_times_reject_an_unbatched_coordinate() -> None:
adapter = _adapter()
@@ -425,6 +438,55 @@ def test_explicit_noise_application_supports_heterogeneous_components() -> None:
assert torch.equal(noised.target_velocity.components[name], noise - clean)
+def test_explicit_noise_application_uses_the_adapter_velocity_direction() -> None:
+ adapter = _structured_adapter(DataWardAdapterFake)
+ clean = LatentState({"video": torch.ones(2, 3), "audio": torch.full((2, 4), 2.0)})
+ noise = LatentState({"video": torch.full((2, 3), 5.0), "audio": torch.full((2, 4), 7.0)})
+
+ noised = adapter.apply_forward_process_noise(clean, _heterogeneous_times(), noise)
+
+ for name in clean.component_names:
+ assert torch.equal(
+ noised.target_velocity.components[name],
+ clean.components[name] - noise.components[name],
+ )
+
+
+def test_replay_projection_preserves_stored_component_sigmas_and_generic_batch() -> None:
+ adapter = _structured_adapter()
+ stored = ComponentTimes(
+ timestep={
+ "video": torch.tensor([990.4219970703125], dtype=torch.float32),
+ "audio": torch.tensor([750.0], dtype=torch.float64),
+ },
+ next_timestep={
+ "video": torch.tensor([0.0], dtype=torch.float32),
+ "audio": torch.tensor([0.0], dtype=torch.float64),
+ },
+ sigma={
+ "video": torch.tensor([0.9904220700263977], dtype=torch.float32),
+ "audio": torch.tensor([0.75], dtype=torch.float64),
+ },
+ next_sigma={
+ "video": torch.tensor([0.0], dtype=torch.float32),
+ "audio": torch.tensor([0.0], dtype=torch.float64),
+ },
+ )
+ adapter.build_training_component_times = lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("stored sigmas are authoritative and must not be reconstructed")
+ )
+
+ resolved = resolve_replay_projection_times(
+ adapter,
+ stored,
+ batch=MappingProxyType({"offline": True}),
+ )
+
+ assert resolved is stored
+ assert resolved.sigma["video"].dtype is torch.float32
+ assert resolved.sigma["audio"].dtype is torch.float64
+
+
def test_explicit_noise_application_consumes_no_randomness() -> None:
adapter = _adapter()
clean = torch.zeros(2, 3)
diff --git a/tests/trainers/test_offline_batch_primitives.py b/tests/trainers/test_offline_batch_primitives.py
new file mode 100644
index 000000000..dbc82d9fb
--- /dev/null
+++ b/tests/trainers/test_offline_batch_primitives.py
@@ -0,0 +1,141 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from collections import namedtuple
+from types import MappingProxyType
+
+import pytest
+import torch
+
+from flow_factory.contracts import NON_MODEL_CONDITION_KEYS
+from flow_factory.trainers.common.offline_batch import (
+ bind_output_forward_context,
+ move_condition_to_device,
+)
+
+
+def test_move_condition_copies_containers_and_moves_nested_tensor_leaves() -> None:
+ tensor = torch.tensor([1.0])
+ nested_list = [tensor]
+ condition = MappingProxyType(
+ {
+ "prompt_embeds": tensor,
+ "nested": {"values": nested_list},
+ "label": "kept on CPU",
+ }
+ )
+
+ moved = move_condition_to_device(condition, "cpu")
+
+ assert moved is not condition
+ assert moved["nested"] is not condition["nested"]
+ assert moved["nested"]["values"] is not nested_list
+ assert moved["prompt_embeds"].device.type == "cpu"
+ assert moved["nested"]["values"][0].device.type == "cpu"
+ assert moved["label"] == "kept on CPU"
+
+
+def test_move_condition_preserves_namedtuple_shape() -> None:
+ Pair = namedtuple("Pair", ("left", "right"))
+ condition = {"pair": Pair(torch.ones(1), [torch.zeros(1)])}
+
+ moved = move_condition_to_device(condition, "cpu")
+
+ assert isinstance(moved["pair"], Pair)
+ assert moved["pair"] is not condition["pair"]
+ assert moved["pair"].right is not condition["pair"].right
+
+
+@pytest.mark.parametrize("value", [{torch.tensor(1)}, frozenset({"field"})])
+def test_move_condition_rejects_unsupported_tree_containers(value: object) -> None:
+ with pytest.raises(TypeError, match="unsupported"):
+ move_condition_to_device({"nested": value}, "cpu")
+
+
+def test_move_condition_rejects_non_boolean_non_blocking() -> None:
+ with pytest.raises(TypeError, match="non_blocking must be a bool"):
+ move_condition_to_device({}, "cpu", non_blocking=1)
+
+
+def test_move_condition_validates_nested_mapping_keys() -> None:
+ with pytest.raises(TypeError, match="string keys"):
+ move_condition_to_device({"nested": {1: torch.ones(1)}}, "cpu")
+
+
+def test_bind_output_context_preserves_input_ownership_without_mutation() -> None:
+ condition = MappingProxyType({"prompt_embeds": torch.ones(1, 2)})
+ context = MappingProxyType({"img_ids": torch.zeros(4, 3)})
+
+ bound = bind_output_forward_context(condition, context)
+
+ assert tuple(bound) == ("prompt_embeds", "img_ids")
+ assert bound["prompt_embeds"] is condition["prompt_embeds"]
+ assert bound["img_ids"] is context["img_ids"]
+ assert tuple(condition) == ("prompt_embeds",)
+ assert tuple(context) == ("img_ids",)
+
+
+def test_bind_output_context_rejects_ambiguous_key_ownership() -> None:
+ with pytest.raises(ValueError, match=r"collides.*\('geometry',\)"):
+ bind_output_forward_context({"geometry": 1}, {"geometry": 2})
+
+
+@pytest.mark.parametrize("side", ["condition", "context"])
+@pytest.mark.parametrize("key", sorted(NON_MODEL_CONDITION_KEYS))
+def test_bind_output_context_rejects_non_model_fields(side: str, key: str) -> None:
+ condition = {key: object()} if side == "condition" else {}
+ context = {key: object()} if side == "context" else {}
+
+ with pytest.raises(ValueError, match=rf"cannot enter model forward.*{key}"):
+ bind_output_forward_context(condition, context)
+
+
+@pytest.mark.parametrize(
+ ("invalid", "expected_error"),
+ [
+ (["not", "a", "mapping"], TypeError),
+ ({1: "not a string key"}, TypeError),
+ ({"": "empty key"}, ValueError),
+ ],
+)
+def test_offline_condition_helpers_reject_invalid_mapping_contract(
+ invalid: object,
+ expected_error: type[Exception],
+) -> None:
+ with pytest.raises(expected_error):
+ move_condition_to_device(invalid, "cpu")
+ with pytest.raises(expected_error):
+ bind_output_forward_context(invalid, {})
+ with pytest.raises(expected_error):
+ bind_output_forward_context({}, invalid)
+
+
+def test_bind_output_context_reports_all_collisions_in_stable_order() -> None:
+ with pytest.raises(ValueError, match=r"\('alpha', 'zeta'\)"):
+ bind_output_forward_context(
+ MappingProxyType({"zeta": 1, "alpha": 2}),
+ MappingProxyType({"alpha": 3, "zeta": 4}),
+ )
+
+
+@pytest.mark.parametrize(
+ "key",
+ ["generator", "loss_weight", "noise", "schema_version", "timestep"],
+)
+def test_algorithm_vocabulary_does_not_close_the_model_condition_namespace(key: str) -> None:
+ marker = object()
+
+ bound = bind_output_forward_context({key: marker}, {})
+
+ assert bound[key] is marker
diff --git a/tests/trainers/test_offline_flow_matching.py b/tests/trainers/test_offline_flow_matching.py
new file mode 100644
index 000000000..c0d32d725
--- /dev/null
+++ b/tests/trainers/test_offline_flow_matching.py
@@ -0,0 +1,274 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from types import MappingProxyType, SimpleNamespace
+from typing import Any
+
+import pytest
+import torch
+
+from flow_factory.contracts import MediaType
+from flow_factory.models.output_state import (
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+from flow_factory.samples import ComponentTimes, LatentState, NoisedState
+from flow_factory.trainers.common.flow_matching import (
+ build_noised_output_state,
+ flow_matching_per_sample_loss,
+ sample_offline_timesteps,
+ validate_preference_output_states,
+)
+
+
+def _signature(height: int = 16, width: int = 16) -> GeometrySignature:
+ return GeometrySignature(
+ media=(MediaGeometrySignature(type=MediaType.IMAGE, height=height, width=width),)
+ )
+
+
+def _encoded(
+ values: torch.Tensor,
+ *,
+ signature: GeometrySignature | None = None,
+ forward_context: dict[str, Any] | None = None,
+ mask: torch.Tensor | None = None,
+) -> EncodedOutputState:
+ return EncodedOutputState(
+ clean_state=LatentState(
+ {"latent": values},
+ active_masks=None if mask is None else {"latent": mask},
+ ),
+ forward_context={} if forward_context is None else forward_context,
+ decode_context={},
+ geometry_signatures=tuple((signature or _signature()) for _ in range(values.shape[0])),
+ )
+
+
+@pytest.mark.parametrize("scheme", ["logit_normal", "uniform"])
+def test_offline_timestep_sampling_materializes_independent_batch_coordinates(
+ scheme: str,
+) -> None:
+ args = SimpleNamespace(
+ weighting_scheme=scheme,
+ num_train_timesteps=3,
+ timestep_range=(0.0, 0.99),
+ time_shift=1.0,
+ logit_mean=0.0,
+ logit_std=1.0,
+ )
+
+ timesteps = sample_offline_timesteps(
+ args,
+ batch_size=4,
+ device="cpu",
+ generator=torch.Generator().manual_seed(7),
+ )
+
+ assert timesteps.shape == (3, 4)
+ assert timesteps.is_contiguous()
+ assert any(not torch.equal(row, row[:1].expand_as(row)) for row in timesteps)
+
+
+def test_build_noised_output_state_reuses_explicit_noise_without_another_draw() -> None:
+ times = ComponentTimes(
+ timestep={"latent": torch.tensor([500.0, 250.0])},
+ next_timestep={"latent": torch.zeros(2)},
+ sigma={"latent": torch.tensor([0.5, 0.25])},
+ next_sigma={"latent": torch.zeros(2)},
+ )
+ clean = LatentState({"latent": torch.zeros(2, 2)})
+ shared_noise = LatentState({"latent": torch.ones(2, 2)})
+ events: list[str] = []
+
+ class Adapter:
+ def build_training_component_times(self, primary: torch.Tensor, *, batch: Any):
+ events.append(f"times:{batch['arm']}")
+ return times
+
+ def add_forward_process_noise(self, *args: Any, **kwargs: Any):
+ raise AssertionError("explicit shared noise must not draw again")
+
+ def apply_forward_process_noise(self, state: Any, component_times: Any, noise: Any):
+ events.append("apply")
+ return NoisedState(state=state, target_velocity=noise, noise=noise)
+
+ returned_times, noised = build_noised_output_state(
+ Adapter(),
+ clean,
+ torch.tensor([500.0, 250.0]),
+ batch=MappingProxyType({"arm": "rejected"}),
+ noise=shared_noise,
+ )
+
+ assert returned_times is times
+ assert noised.noise is shared_noise
+ assert events == ["times:rejected", "apply"]
+
+ with pytest.raises(ValueError, match="mutually exclusive"):
+ build_noised_output_state(
+ Adapter(),
+ clean,
+ torch.tensor([500.0, 250.0]),
+ batch={"arm": "chosen"},
+ generator=torch.Generator(),
+ noise=shared_noise,
+ )
+
+
+def test_preference_arms_share_coordinates_and_noise_but_keep_their_own_batches() -> None:
+ seen_batches: list[str] = []
+
+ class Adapter:
+ def build_training_component_times(self, primary: torch.Tensor, *, batch: Any):
+ seen_batches.append(batch["arm"])
+ sigma = primary.to(torch.float64).div(1000).to(primary.dtype)
+ return ComponentTimes(
+ timestep={"latent": primary},
+ next_timestep={"latent": torch.zeros_like(primary)},
+ sigma={"latent": sigma},
+ next_sigma={"latent": torch.zeros_like(sigma)},
+ )
+
+ def add_forward_process_noise(
+ self,
+ clean_state: LatentState,
+ times: ComponentTimes,
+ *,
+ generator: torch.Generator | None,
+ ) -> NoisedState:
+ clean = clean_state.components["latent"]
+ noise = LatentState(
+ {
+ "latent": torch.randn(
+ clean.shape,
+ generator=generator,
+ device=clean.device,
+ dtype=clean.dtype,
+ )
+ }
+ )
+ return self.apply_forward_process_noise(clean_state, times, noise)
+
+ def apply_forward_process_noise(
+ self,
+ clean_state: LatentState,
+ times: ComponentTimes,
+ noise: LatentState,
+ ) -> NoisedState:
+ clean = clean_state.components["latent"]
+ sigma = times.sigma["latent"].unsqueeze(1).to(clean)
+ noised = (1 - sigma) * clean + sigma * noise.components["latent"]
+ target = noise.components["latent"] - clean
+ return NoisedState(
+ state=LatentState({"latent": noised}),
+ target_velocity=LatentState({"latent": target}),
+ noise=noise,
+ )
+
+ adapter = Adapter()
+ primary = torch.tensor([725.0, 125.0])
+ chosen_times, chosen = build_noised_output_state(
+ adapter,
+ LatentState({"latent": torch.zeros(2, 3)}),
+ primary,
+ batch={"arm": "chosen"},
+ generator=torch.Generator().manual_seed(19),
+ )
+ rejected_times, rejected = build_noised_output_state(
+ adapter,
+ LatentState({"latent": torch.ones(2, 3)}),
+ primary,
+ batch={"arm": "rejected"},
+ noise=chosen.noise,
+ )
+
+ assert seen_batches == ["chosen", "rejected"]
+ assert torch.equal(chosen_times.timestep["latent"], rejected_times.timestep["latent"])
+ assert torch.equal(chosen_times.sigma["latent"], rejected_times.sigma["latent"])
+ assert rejected.noise is chosen.noise
+
+
+def test_flow_matching_loss_computes_fp32_errors_before_adapter_reduction() -> None:
+ predicted = LatentState(
+ {
+ "video": torch.tensor([[1.0, 3.0], [2.0, 4.0]], dtype=torch.float16),
+ "audio": torch.tensor([[5.0], [7.0]], dtype=torch.float16),
+ }
+ )
+ target = LatentState(
+ {
+ "video": torch.tensor([[0.0, 1.0], [1.0, 2.0]], dtype=torch.float16),
+ "audio": torch.tensor([[2.0], [3.0]], dtype=torch.float16),
+ }
+ )
+ state = LatentState(
+ {
+ "video": torch.zeros(2, 2, dtype=torch.float16),
+ "audio": torch.zeros(2, 1, dtype=torch.float16),
+ }
+ )
+ received: dict[str, Any] = {}
+
+ class Adapter:
+ def reduce_latent_values(self, values: Any, *, state: Any):
+ received["values"] = values
+ received["state"] = state
+ total = torch.cat([value.flatten(1) for value in values.values()], dim=1)
+ return total.mean(dim=1)
+
+ noised = NoisedState(state=state, target_velocity=target, noise=target)
+ loss = flow_matching_per_sample_loss(Adapter(), predicted, noised)
+
+ torch.testing.assert_close(loss, torch.tensor([(1.0 + 4.0 + 9.0) / 3, 7.0]))
+ assert all(value.dtype is torch.float32 for value in received["values"].values())
+ assert received["state"] is state
+
+
+def test_preference_state_validation_accepts_content_specific_context_values() -> None:
+ chosen = _encoded(
+ torch.zeros(2, 4),
+ forward_context={"context": torch.zeros(2, 3), "label": "chosen"},
+ )
+ rejected = _encoded(
+ torch.ones(2, 4),
+ forward_context={"context": torch.ones(2, 3), "label": "rejected"},
+ )
+
+ validate_preference_output_states(chosen, rejected)
+
+
+@pytest.mark.parametrize("mismatch", ["shape", "geometry", "mask", "context"])
+def test_preference_state_validation_rejects_incompatible_forward_processes(
+ mismatch: str,
+) -> None:
+ chosen_mask = torch.ones(2, 1, dtype=torch.bool)
+ rejected_mask = chosen_mask.clone()
+ chosen = _encoded(
+ torch.zeros(2, 4),
+ mask=chosen_mask if mismatch == "mask" else None,
+ forward_context={"ids": torch.zeros(4, 3)},
+ )
+ rejected = _encoded(
+ torch.zeros(2, 5) if mismatch == "shape" else torch.ones(2, 4),
+ signature=_signature(32, 16) if mismatch == "geometry" else None,
+ mask=rejected_mask.logical_not() if mismatch == "mask" else None,
+ forward_context={
+ "ids": torch.zeros(5 if mismatch == "context" else 4, 3),
+ },
+ )
+
+ with pytest.raises((TypeError, ValueError), match="preference arm"):
+ validate_preference_output_states(chosen, rejected)
From 736102ca98de810247ee2c60d647c820c8788e80 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:16:52 +0800
Subject: [PATCH 09/76] feat(checkpoint): add safe trainer runtime state
---
src/flow_factory/ema/ema.py | 178 +++-
.../trainers/common/runtime_state.py | 972 ++++++++++++++++++
tests/trainers/test_ema_checkpoint_state.py | 182 ++++
tests/trainers/test_runtime_state.py | 335 ++++++
4 files changed, 1658 insertions(+), 9 deletions(-)
create mode 100644 src/flow_factory/trainers/common/runtime_state.py
create mode 100644 tests/trainers/test_ema_checkpoint_state.py
create mode 100644 tests/trainers/test_runtime_state.py
diff --git a/src/flow_factory/ema/ema.py b/src/flow_factory/ema/ema.py
index 3cdd2db5a..0be7dab4c 100644
--- a/src/flow_factory/ema/ema.py
+++ b/src/flow_factory/ema/ema.py
@@ -17,9 +17,9 @@
EMA Module Wrapper with functional decay scheduling.
"""
-from collections.abc import Iterable
+from collections.abc import Iterable, Mapping
from contextlib import contextmanager
-from typing import Literal, Optional
+from typing import Any, Literal, Optional
import torch
@@ -29,6 +29,19 @@
logger = setup_logger(__name__)
+EMA_STATE_VERSION = 1
+_EMA_STATE_KEYS = frozenset(
+ {
+ "version",
+ "decay",
+ "update_step_interval",
+ "ema_parameters",
+ "num_updates",
+ "decay_schedule",
+ "schedule_params",
+ }
+)
+
class EMAModuleWrapper:
"""
@@ -179,21 +192,168 @@ def use_ema_parameters(self, parameters: Iterable[torch.nn.Parameter]):
def state_dict(self) -> dict:
"""Save state for checkpointing."""
- return {
+ for index, parameter in enumerate(self.ema_parameters):
+ if type(parameter) is not torch.Tensor:
+ raise TypeError(
+ "EMA runtime checkpointing supports only replicated plain tensors; "
+ f"parameter {index} is {type(parameter).__name__}. Sharded/DTensor EMA "
+ "state requires a distributed-aware gather implementation."
+ )
+ state = {
+ "version": EMA_STATE_VERSION,
"decay": self.decay,
+ "update_step_interval": self.update_step_interval,
"ema_parameters": self.ema_parameters,
"num_updates": self.num_updates,
"decay_schedule": self._decay_schedule,
- "schedule_params": self._schedule_params,
+ "schedule_params": dict(self._schedule_params),
}
+ # Saving while EMA weights are temporarily installed would pair the EMA
+ # policy with the pre-swap runtime payload and make resume permanently use
+ # the wrong live parameters. Reuse the strict, non-mutating load validator
+ # so every state we emit is also one this wrapper can restore.
+ self._validated_state_dict(state)
+ return state
+
+ def validate_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Validate a checkpoint payload without mutating the live EMA state."""
+ self._validated_state_dict(state_dict)
- def load_state_dict(self, state_dict: dict) -> None:
- """Load state from checkpoint."""
- self.decay = state_dict.get("decay", self.decay)
- self.ema_parameters = state_dict["ema_parameters"]
- self.num_updates = state_dict.get("num_updates", 0)
+ def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Load a compatible replicated EMA state after complete validation."""
+ parameters, num_updates = self._validated_state_dict(state_dict)
+ self.ema_parameters = [parameter.detach().clone() for parameter in parameters]
+ self.num_updates = num_updates
self.to(self.device)
+ def _validated_state_dict(
+ self,
+ state_dict: Mapping[str, Any],
+ ) -> tuple[list[torch.Tensor], int]:
+ """Return validated tensor and counter values without changing this wrapper."""
+ if not isinstance(state_dict, Mapping):
+ raise TypeError(
+ "expected EMA state as a mapping, "
+ f"received {type(state_dict).__name__}: {state_dict!r}"
+ )
+ non_string_keys = tuple(key for key in state_dict if type(key) is not str)
+ if non_string_keys:
+ raise TypeError(f"expected EMA state keys to be str, received {non_string_keys!r}")
+ received_keys = frozenset(state_dict)
+ if received_keys != _EMA_STATE_KEYS:
+ raise ValueError(
+ f"EMA state keys mismatch: expected {tuple(sorted(_EMA_STATE_KEYS))!r}, "
+ f"received {tuple(sorted(received_keys))!r}"
+ )
+
+ version = state_dict["version"]
+ if type(version) is not int:
+ raise TypeError(
+ "expected EMA state version to be int, "
+ f"received {type(version).__name__}: {version!r}"
+ )
+ if version != EMA_STATE_VERSION:
+ raise ValueError(
+ f"EMA state version mismatch: expected {EMA_STATE_VERSION}, received {version}"
+ )
+
+ decay = state_dict["decay"]
+ if type(decay) not in (int, float):
+ raise TypeError(
+ "expected EMA state decay to be int or float, "
+ f"received {type(decay).__name__}: {decay!r}"
+ )
+ if float(decay) != float(self.decay):
+ raise ValueError(f"EMA state decay mismatch: expected {self.decay}, received {decay}")
+
+ update_step_interval = state_dict["update_step_interval"]
+ if type(update_step_interval) is not int:
+ raise TypeError(
+ "expected EMA state update_step_interval to be int, "
+ f"received {type(update_step_interval).__name__}: "
+ f"{update_step_interval!r}"
+ )
+ if update_step_interval < 0:
+ raise ValueError(
+ "expected EMA state update_step_interval as a non-negative int, "
+ f"received {update_step_interval!r}"
+ )
+ if update_step_interval != self.update_step_interval:
+ raise ValueError(
+ "EMA state update_step_interval mismatch: expected "
+ f"{self.update_step_interval}, received {update_step_interval}"
+ )
+
+ decay_schedule = state_dict["decay_schedule"]
+ if type(decay_schedule) is not str:
+ raise TypeError(
+ "expected EMA state decay_schedule to be str, "
+ f"received {type(decay_schedule).__name__}: {decay_schedule!r}"
+ )
+ if decay_schedule != self._decay_schedule:
+ raise ValueError(
+ "EMA state decay_schedule mismatch: expected "
+ f"{self._decay_schedule!r}, received {decay_schedule!r}"
+ )
+
+ schedule_params = state_dict["schedule_params"]
+ if not isinstance(schedule_params, Mapping):
+ raise TypeError(
+ "expected EMA state schedule_params as a mapping, "
+ f"received {type(schedule_params).__name__}: {schedule_params!r}"
+ )
+ if dict(schedule_params) != self._schedule_params:
+ raise ValueError(
+ "EMA state schedule_params mismatch: expected "
+ f"{self._schedule_params!r}, received {dict(schedule_params)!r}"
+ )
+
+ num_updates = state_dict["num_updates"]
+ if type(num_updates) is not int:
+ raise TypeError(
+ "expected EMA state num_updates to be int, "
+ f"received {type(num_updates).__name__}: {num_updates!r}"
+ )
+ if num_updates < 0:
+ raise ValueError(
+ "expected EMA state num_updates as a non-negative int, " f"received {num_updates!r}"
+ )
+
+ parameters = state_dict["ema_parameters"]
+ if not isinstance(parameters, list):
+ raise TypeError(
+ "expected EMA state ema_parameters as a list, "
+ f"received {type(parameters).__name__}: {parameters!r}"
+ )
+ if len(parameters) != len(self.ema_parameters):
+ raise ValueError(
+ "EMA state parameter count mismatch: expected "
+ f"{len(self.ema_parameters)}, received {len(parameters)}"
+ )
+ for index, (received, expected) in enumerate(
+ zip(parameters, self.ema_parameters, strict=True)
+ ):
+ if type(received) is not torch.Tensor:
+ raise TypeError(
+ "expected EMA state parameter "
+ f"{index} to be a plain torch.Tensor, received "
+ f"{type(received).__name__}: {received!r}"
+ )
+ if received.shape != expected.shape:
+ raise ValueError(
+ f"EMA state parameter {index} shape mismatch: expected "
+ f"{tuple(expected.shape)}, received {tuple(received.shape)}"
+ )
+ if received.dtype != expected.dtype:
+ raise ValueError(
+ f"EMA state parameter {index} dtype mismatch: expected "
+ f"{expected.dtype}, received {received.dtype}"
+ )
+
+ if self.temp_stored_parameters is not None:
+ raise RuntimeError("cannot load EMA state while EMA parameters are installed")
+ return parameters, num_updates
+
@staticmethod
def get_decay_for_impact(impact: float, num_steps: int) -> float:
"""Calculate decay to achieve specific impact after num_steps."""
diff --git a/src/flow_factory/trainers/common/runtime_state.py b/src/flow_factory/trainers/common/runtime_state.py
new file mode 100644
index 000000000..bff6d659d
--- /dev/null
+++ b/src/flow_factory/trainers/common/runtime_state.py
@@ -0,0 +1,972 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Safe checkpoint storage for offline trainer progress and child state."""
+
+import hashlib
+import json
+import math
+import os
+import re
+import uuid
+from collections.abc import Iterable, Mapping
+from typing import Any, Protocol
+
+import torch
+from accelerate.utils import load as accelerate_load
+from safetensors.torch import load_file, save_file
+
+from ..execution import TrainingProgress
+
+TRAINER_RUNTIME_STATE_VERSION = 1
+TRAINER_RUNTIME_FORMAT = "flow_factory.trainer_runtime"
+TRAINER_RUNTIME_METADATA_FILENAME = "flow_factory_trainer_runtime.json"
+TRAINER_RUNTIME_TENSOR_PREFIX = "flow_factory_trainer_runtime"
+_RUNTIME_STATE_KEYS = frozenset({"version", "progress", "children"})
+_PROGRESS_KEYS = frozenset({"optimizer_step", "rollout_iteration", "data_epoch"})
+_METADATA_KEYS = frozenset(
+ {
+ "format",
+ "version",
+ "identity",
+ "child_names",
+ "state_files",
+ "tensor_file",
+ "state",
+ }
+)
+_STATE_FILE_KEYS = frozenset({"path", "size", "sha256"})
+_IDENTITY_KEYS = frozenset(
+ {
+ "trainer",
+ "adapter",
+ "algorithm",
+ "model",
+ "finetune_type",
+ "optimizer_roles",
+ "parameter_schema_digest",
+ "optimizer_schema_digest",
+ "world_size",
+ }
+)
+_LEGACY_CUSTOM_STATE_PATTERN = re.compile(r"^custom_checkpoint_\d+\.pkl$")
+_TENSOR_FILENAME_PATTERN = re.compile(
+ rf"^{TRAINER_RUNTIME_TENSOR_PREFIX}\.[0-9a-f]{{32}}\.safetensors$"
+)
+_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+_NODE_TYPES = frozenset(
+ {"mapping", "list", "tuple", "none", "bool", "int", "float", "str", "tensor"}
+)
+
+
+class CheckpointableChild(Protocol):
+ """Structural interface required from a named runtime child."""
+
+ def state_dict(self) -> Mapping[str, Any]:
+ """Return serializable child state."""
+ ...
+
+ def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Restore child state."""
+ ...
+
+ def validate_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Validate child state without mutating the live child."""
+ ...
+
+
+class TrainerRuntimeState:
+ """Own offline progress and late-bound EMA/reference checkpoint state.
+
+ Runtime payloads intentionally do not use Accelerate's generic custom checkpoint
+ objects because those objects are serialized with pickle. The framework writes a
+ strictly tagged JSON tree and plain tensors in safetensors instead. A load is first
+ decoded and validated without mutation, then committed only after Accelerate has
+ successfully restored the policy, optimizer, scheduler, and RNG state.
+ """
+
+ checkpoint_id = "flow_factory.trainer_runtime.v1"
+
+ def __init__(
+ self,
+ progress: TrainingProgress | None = None,
+ *,
+ child_names: Iterable[str] = (),
+ identity: Mapping[str, Any] | None = None,
+ ) -> None:
+ self._progress = _require_progress(TrainingProgress() if progress is None else progress)
+ self._child_names = _normalize_child_names(child_names)
+ self._identity = _normalize_identity({} if identity is None else identity)
+ self._children: dict[str, CheckpointableChild] = {}
+ self._pending_child_states: dict[str, Mapping[str, Any]] = {}
+ self._validated_load: tuple[TrainingProgress, dict[str, Mapping[str, Any]]] | None = None
+ self._load_received = False
+
+ @property
+ def progress(self) -> TrainingProgress:
+ """Return the current immutable progress value."""
+ return self._progress
+
+ @progress.setter
+ def progress(self, progress: TrainingProgress) -> None:
+ """Replace the current progress value after strict type validation."""
+ self._progress = _require_progress(progress)
+
+ @property
+ def child_names(self) -> tuple[str, ...]:
+ """Return the immutable child declaration order."""
+ return self._child_names
+
+ @property
+ def identity(self) -> dict[str, Any]:
+ """Return the immutable-compatible state-resume identity fields."""
+ return {**self._identity, "optimizer_roles": list(self._identity["optimizer_roles"])}
+
+ @property
+ def pending_child_names(self) -> tuple[str, ...]:
+ """Return restored children that have not yet consumed their payload."""
+ return tuple(name for name in self._child_names if name in self._pending_child_states)
+
+ @property
+ def load_received(self) -> bool:
+ """Return whether a validated runtime payload has been committed."""
+ return self._load_received
+
+ @property
+ def validated_load_pending(self) -> bool:
+ """Return whether a preflighted payload is waiting for policy-state restore."""
+ return self._validated_load is not None
+
+ def configure_identity(self, identity: Mapping[str, Any]) -> None:
+ """Bind the realized trainer/model/optimizer layout exactly once."""
+ normalized = _normalize_identity(identity)
+ if self._identity["trainer"] != "unspecified":
+ if self._identity != normalized:
+ raise RuntimeError(
+ "trainer runtime identity changed after configuration: expected "
+ f"{self._identity!r}, received {normalized!r}"
+ )
+ return
+ self._identity = normalized
+
+ def prepare_save(self, output_dir: str | os.PathLike[str]) -> None:
+ """Atomically publish a JSON manifest and safetensors runtime payload.
+
+ The tensor generation is written first and the JSON manifest is the commit
+ marker. Its generated basename prevents an interrupted overwrite from pairing
+ old JSON state with new tensors.
+ """
+ state = self.state_dict()
+ tensors: dict[str, torch.Tensor] = {}
+ encoded_state = _encode_node(state, tensors=tensors, path="runtime")
+ output_path = os.fspath(output_dir)
+ os.makedirs(output_path, exist_ok=True)
+
+ metadata_path = os.path.join(output_path, TRAINER_RUNTIME_METADATA_FILENAME)
+ if os.path.exists(metadata_path):
+ raise FileExistsError(
+ "trainer runtime checkpoints are immutable and cannot overwrite an "
+ f"existing manifest: {metadata_path!r}"
+ )
+
+ tensor_filename = f"{TRAINER_RUNTIME_TENSOR_PREFIX}.{uuid.uuid4().hex}.safetensors"
+ tensor_path = os.path.join(output_path, tensor_filename)
+ tensor_temp_path = f"{tensor_path}.tmp"
+ metadata_temp_path = f"{metadata_path}.tmp"
+ state_files = _collect_accelerate_state_files(output_path)
+
+ try:
+ save_file(tensors, tensor_temp_path)
+ os.replace(tensor_temp_path, tensor_path)
+ metadata = {
+ "format": TRAINER_RUNTIME_FORMAT,
+ "version": TRAINER_RUNTIME_STATE_VERSION,
+ "identity": self.identity,
+ "child_names": list(self._child_names),
+ "state_files": state_files,
+ "tensor_file": _describe_file(tensor_path, tensor_filename),
+ "state": encoded_state,
+ }
+ with open(metadata_temp_path, "w", encoding="utf-8") as metadata_file:
+ json.dump(
+ metadata,
+ metadata_file,
+ allow_nan=False,
+ indent=2,
+ sort_keys=True,
+ )
+ metadata_file.write("\n")
+ os.replace(metadata_temp_path, metadata_path)
+ finally:
+ for temporary_path in (tensor_temp_path, metadata_temp_path):
+ try:
+ os.unlink(temporary_path)
+ except FileNotFoundError:
+ pass
+
+ def validate_load(
+ self,
+ input_dir: str | os.PathLike[str],
+ *,
+ children: Mapping[str, CheckpointableChild] | None = None,
+ ) -> None:
+ """Decode and stage compatible state before policy/optimizer mutation."""
+ if self._load_received:
+ raise RuntimeError("trainer runtime state has already received a checkpoint load")
+ if self._validated_load is not None:
+ raise RuntimeError("trainer runtime state already has a validated pending load")
+ if self._children:
+ raise RuntimeError(
+ "trainer runtime state must validate before attaching children; already "
+ f"attached children: {tuple(self._children)!r}"
+ )
+
+ input_path = os.fspath(input_dir)
+ legacy_files = tuple(
+ sorted(
+ filename
+ for filename in os.listdir(input_path)
+ if _LEGACY_CUSTOM_STATE_PATTERN.fullmatch(filename) is not None
+ )
+ )
+ if legacy_files:
+ raise RuntimeError(
+ "offline state checkpoint contains legacy or foreign pickle custom state "
+ f"files {legacy_files!r}; this runtime accepts only JSON + safetensors. "
+ "Resume model weights instead or regenerate a trusted state checkpoint."
+ )
+
+ metadata_path = os.path.join(input_path, TRAINER_RUNTIME_METADATA_FILENAME)
+ if not os.path.isfile(metadata_path):
+ raise RuntimeError(
+ "state checkpoint is incompatible with trainer runtime-state v1: expected "
+ f"metadata file {metadata_path!r}, received missing file. Checkpoints created "
+ "before safe runtime-state v1 did not serialize TrainingProgress and "
+ "EMA/reference state safely; resume their model weights instead of using "
+ "resume_type='state'."
+ )
+ with open(metadata_path, "r", encoding="utf-8") as metadata_file:
+ metadata = json.load(
+ metadata_file,
+ object_pairs_hook=_reject_duplicate_object_pairs,
+ parse_constant=_reject_json_constant,
+ )
+ metadata = _require_mapping(metadata, "trainer runtime metadata")
+ _require_exact_keys(metadata, _METADATA_KEYS, "trainer runtime metadata")
+ _validate_metadata_header(metadata, self._child_names, self._identity)
+ _validate_accelerate_state_files(
+ input_path,
+ metadata["state_files"],
+ require_complete=self._identity["trainer"] != "unspecified",
+ )
+
+ tensor_filename = _validate_checkpoint_file(
+ input_path,
+ metadata["tensor_file"],
+ context="trainer runtime tensor_file",
+ )
+ if not _TENSOR_FILENAME_PATTERN.fullmatch(tensor_filename):
+ raise ValueError(
+ "trainer runtime metadata tensor_file path must be a generated runtime "
+ f"safetensors basename, received {tensor_filename!r}"
+ )
+ tensor_path = os.path.join(input_path, tensor_filename)
+ tensors = load_file(tensor_path, device="cpu")
+ consumed_tensors: set[str] = set()
+ state = _decode_node(
+ metadata["state"],
+ tensors=tensors,
+ consumed_tensors=consumed_tensors,
+ path="runtime",
+ )
+ extra_tensors = frozenset(tensors).difference(consumed_tensors)
+ if extra_tensors:
+ raise ValueError(
+ "trainer runtime safetensors contains unreferenced tensors: "
+ f"{tuple(sorted(extra_tensors))!r}"
+ )
+
+ progress, child_payloads = _decode_runtime_state(state, self._child_names)
+ if children is None:
+ if self._child_names:
+ raise RuntimeError(
+ "trainer runtime resume preflight requires realized child validators for "
+ f"{self._child_names!r}"
+ )
+ else:
+ children = _require_mapping(children, "trainer runtime preflight children")
+ _require_exact_keys(
+ children,
+ frozenset(self._child_names),
+ "trainer runtime preflight children",
+ )
+ for name in self._child_names:
+ child = children[name]
+ _require_checkpointable_child(child, name, require_validator=True)
+ child.validate_state_dict(child_payloads[name])
+ self._validated_load = (progress, child_payloads)
+
+ def commit_validated_load(self) -> None:
+ """Commit the preflighted runtime payload after Accelerate succeeds."""
+ if self._validated_load is None:
+ raise RuntimeError("trainer runtime state has no validated load to commit")
+ progress, children = self._validated_load
+ self._install_loaded_state(progress, children)
+ self._validated_load = None
+
+ def attach_child(self, name: str, child: CheckpointableChild) -> None:
+ """Attach one declared child and consume its pending payload if present."""
+ _require_child_name(name)
+ if name not in self._child_names:
+ raise KeyError(
+ f"runtime child {name!r} was not declared; expected one of "
+ f"{self._child_names!r}"
+ )
+ if name in self._children:
+ raise RuntimeError(f"runtime child {name!r} is already attached")
+ _require_checkpointable_child(child, name)
+
+ if name in self._pending_child_states:
+ payload = self._pending_child_states[name]
+ child.load_state_dict(dict(payload))
+ del self._pending_child_states[name]
+ self._children[name] = child
+
+ def state_dict(self) -> dict[str, Any]:
+ """Return progress and every declared child for safe checkpointing."""
+ children: dict[str, dict[str, Any]] = {}
+ for name in self._child_names:
+ if name in self._children:
+ payload = self._children[name].state_dict()
+ payload = _require_child_payload(payload, name)
+ elif name in self._pending_child_states:
+ payload = self._pending_child_states[name]
+ else:
+ raise RuntimeError(
+ f"cannot serialize runtime child {name!r}: it is neither attached "
+ "nor backed by a pending restored payload"
+ )
+ children[name] = dict(payload)
+
+ progress = self._progress
+ return {
+ "version": TRAINER_RUNTIME_STATE_VERSION,
+ "progress": {
+ "optimizer_step": progress.optimizer_step,
+ "rollout_iteration": progress.rollout_iteration,
+ "data_epoch": progress.data_epoch,
+ },
+ "children": children,
+ }
+
+ def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Load an already-decoded payload for narrow in-process integrations."""
+ progress, children = _decode_runtime_state(state_dict, self._child_names)
+ self._install_loaded_state(progress, children)
+
+ def _install_loaded_state(
+ self,
+ progress: TrainingProgress,
+ children: dict[str, Mapping[str, Any]],
+ ) -> None:
+ """Install one decoded payload exactly once before child attachment."""
+ if self._load_received:
+ raise RuntimeError("trainer runtime state has already received a checkpoint load")
+ if self._children:
+ raise RuntimeError(
+ "trainer runtime state must load before attaching children; already attached "
+ f"children: {tuple(self._children)!r}"
+ )
+ self._progress = progress
+ self._pending_child_states = children
+ self._load_received = True
+
+
+def _encode_node(
+ value: Any,
+ *,
+ tensors: dict[str, torch.Tensor],
+ path: str,
+) -> dict[str, Any]:
+ """Encode one strictly typed tree node and extract every tensor leaf."""
+ if type(value) is torch.Tensor:
+ if value.layout is not torch.strided or value.is_sparse or value.is_complex():
+ raise TypeError(
+ f"runtime tensor at {path} must be a dense real strided tensor, "
+ f"received layout={value.layout}, dtype={value.dtype}"
+ )
+ if value.device.type == "meta":
+ raise TypeError(f"runtime tensor at {path} cannot reside on the meta device")
+ name = f"tensor_{len(tensors):08d}"
+ tensors[name] = value.detach().to(device="cpu").contiguous().clone()
+ return {"type": "tensor", "name": name}
+ if isinstance(value, torch.Tensor):
+ raise TypeError(
+ f"runtime tensor at {path} must be a plain torch.Tensor, "
+ f"received {type(value).__name__}"
+ )
+ if isinstance(value, Mapping):
+ items = []
+ for key, item in value.items():
+ if type(key) is not str:
+ raise TypeError(
+ f"runtime mapping key at {path} must be str, "
+ f"received {type(key).__name__}: {key!r}"
+ )
+ items.append([key, _encode_node(item, tensors=tensors, path=f"{path}.{key}")])
+ return {"type": "mapping", "items": items}
+ if isinstance(value, list):
+ return {
+ "type": "list",
+ "items": [
+ _encode_node(item, tensors=tensors, path=f"{path}[{index}]")
+ for index, item in enumerate(value)
+ ],
+ }
+ if isinstance(value, tuple):
+ return {
+ "type": "tuple",
+ "items": [
+ _encode_node(item, tensors=tensors, path=f"{path}[{index}]")
+ for index, item in enumerate(value)
+ ],
+ }
+ if value is None:
+ return {"type": "none"}
+ if type(value) is bool:
+ return {"type": "bool", "value": value}
+ if type(value) is int:
+ return {"type": "int", "value": value}
+ if type(value) is float:
+ if not math.isfinite(value):
+ raise ValueError(f"runtime float at {path} must be finite, received {value!r}")
+ return {"type": "float", "value": value}
+ if type(value) is str:
+ return {"type": "str", "value": value}
+ raise TypeError(f"unsupported runtime state value at {path}: {type(value).__name__}: {value!r}")
+
+
+def _decode_node(
+ node: Any,
+ *,
+ tensors: Mapping[str, torch.Tensor],
+ consumed_tensors: set[str],
+ path: str,
+) -> Any:
+ """Decode one tagged JSON node without constructing executable objects."""
+ node = _require_mapping(node, f"encoded runtime node at {path}")
+ node_type = node.get("type")
+ if type(node_type) is not str or node_type not in _NODE_TYPES:
+ raise ValueError(f"encoded runtime node at {path} has invalid type tag {node_type!r}")
+ if node_type in ("mapping", "list", "tuple"):
+ _require_exact_keys(node, frozenset({"type", "items"}), f"{node_type} node at {path}")
+ items = node["items"]
+ if not isinstance(items, list):
+ raise TypeError(
+ f"encoded runtime {node_type} items at {path} must be list, "
+ f"received {type(items).__name__}: {items!r}"
+ )
+ if node_type == "mapping":
+ result: dict[str, Any] = {}
+ for index, pair in enumerate(items):
+ if not isinstance(pair, list) or len(pair) != 2:
+ raise TypeError(
+ f"encoded runtime mapping item {index} at {path} must be a "
+ f"two-item list, received {pair!r}"
+ )
+ key, child_node = pair
+ if type(key) is not str:
+ raise TypeError(
+ f"encoded runtime mapping key at {path} must be str, "
+ f"received {type(key).__name__}: {key!r}"
+ )
+ if key in result:
+ raise ValueError(
+ f"encoded runtime mapping at {path} contains duplicate key {key!r}"
+ )
+ result[key] = _decode_node(
+ child_node,
+ tensors=tensors,
+ consumed_tensors=consumed_tensors,
+ path=f"{path}.{key}",
+ )
+ return result
+ decoded_items = [
+ _decode_node(
+ item,
+ tensors=tensors,
+ consumed_tensors=consumed_tensors,
+ path=f"{path}[{index}]",
+ )
+ for index, item in enumerate(items)
+ ]
+ return decoded_items if node_type == "list" else tuple(decoded_items)
+ if node_type == "none":
+ _require_exact_keys(node, frozenset({"type"}), f"none node at {path}")
+ return None
+ if node_type in ("bool", "int", "float", "str"):
+ _require_exact_keys(node, frozenset({"type", "value"}), f"{node_type} node at {path}")
+ value = node["value"]
+ expected_type = {"bool": bool, "int": int, "float": float, "str": str}[node_type]
+ if type(value) is not expected_type:
+ raise TypeError(
+ f"encoded runtime {node_type} at {path} must contain "
+ f"{expected_type.__name__}, received {type(value).__name__}: {value!r}"
+ )
+ if node_type == "float" and not math.isfinite(value):
+ raise ValueError(f"encoded runtime float at {path} must be finite")
+ return value
+
+ _require_exact_keys(node, frozenset({"type", "name"}), f"tensor node at {path}")
+ tensor_name = node["name"]
+ if type(tensor_name) is not str or not tensor_name:
+ raise TypeError(
+ f"encoded runtime tensor name at {path} must be a non-empty str, "
+ f"received {type(tensor_name).__name__}: {tensor_name!r}"
+ )
+ if tensor_name in consumed_tensors:
+ raise ValueError(f"encoded runtime state references tensor {tensor_name!r} more than once")
+ if tensor_name not in tensors:
+ raise ValueError(f"encoded runtime state references missing tensor {tensor_name!r}")
+ tensor = tensors[tensor_name]
+ if type(tensor) is not torch.Tensor:
+ raise TypeError(
+ f"runtime safetensors value {tensor_name!r} must be a plain torch.Tensor, "
+ f"received {type(tensor).__name__}"
+ )
+ consumed_tensors.add(tensor_name)
+ return tensor
+
+
+def _decode_runtime_state(
+ state_dict: Mapping[str, Any], expected_child_names: tuple[str, ...]
+) -> tuple[TrainingProgress, dict[str, Mapping[str, Any]]]:
+ """Validate one serialized runtime state without mutating its receiver."""
+ state_dict = _require_mapping(state_dict, "trainer runtime state")
+ _require_exact_keys(state_dict, _RUNTIME_STATE_KEYS, "trainer runtime state")
+
+ version = state_dict["version"]
+ if type(version) is not int:
+ raise TypeError(
+ "expected trainer runtime state version to be int, "
+ f"received {type(version).__name__}: {version!r}"
+ )
+ if version != TRAINER_RUNTIME_STATE_VERSION:
+ raise ValueError(
+ "trainer runtime state version mismatch: expected "
+ f"{TRAINER_RUNTIME_STATE_VERSION}, received {version}"
+ )
+
+ raw_progress = _require_mapping(state_dict["progress"], "trainer runtime progress")
+ _require_exact_keys(raw_progress, _PROGRESS_KEYS, "trainer runtime progress")
+ progress = TrainingProgress(
+ optimizer_step=_require_counter(raw_progress["optimizer_step"], "optimizer_step"),
+ rollout_iteration=_require_counter(raw_progress["rollout_iteration"], "rollout_iteration"),
+ data_epoch=_require_counter(raw_progress["data_epoch"], "data_epoch"),
+ )
+
+ raw_children = _require_mapping(state_dict["children"], "trainer runtime children")
+ _require_exact_keys(raw_children, frozenset(expected_child_names), "trainer runtime children")
+ children = {
+ name: dict(_require_child_payload(raw_children[name], name))
+ for name in expected_child_names
+ }
+ return progress, children
+
+
+def _normalize_identity(identity: Mapping[str, Any]) -> dict[str, Any]:
+ """Validate exact state-resume identity without accepting lookalike runs."""
+ identity = _require_mapping(identity, "trainer runtime identity")
+ if not identity:
+ return {
+ "trainer": "unspecified",
+ "adapter": "unspecified",
+ "algorithm": "unspecified",
+ "model": "unspecified",
+ "finetune_type": "unspecified",
+ "optimizer_roles": (),
+ "parameter_schema_digest": "unspecified",
+ "optimizer_schema_digest": "unspecified",
+ "world_size": 1,
+ }
+ _require_exact_keys(identity, _IDENTITY_KEYS, "trainer runtime identity")
+ normalized: dict[str, Any] = {}
+ for field_name in (
+ "trainer",
+ "adapter",
+ "algorithm",
+ "model",
+ "finetune_type",
+ "parameter_schema_digest",
+ "optimizer_schema_digest",
+ ):
+ value = identity[field_name]
+ if type(value) is not str or not value:
+ raise TypeError(
+ f"trainer runtime identity {field_name} must be a non-empty str, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ normalized[field_name] = value
+ optimizer_roles = identity["optimizer_roles"]
+ if isinstance(optimizer_roles, (str, bytes)):
+ raise TypeError("trainer runtime identity optimizer_roles must be a sequence")
+ try:
+ optimizer_roles = tuple(optimizer_roles)
+ except TypeError as error:
+ raise TypeError("trainer runtime identity optimizer_roles must be a sequence") from error
+ for role in optimizer_roles:
+ if type(role) is not str or not role:
+ raise TypeError(
+ "trainer runtime identity optimizer role must be a non-empty str, "
+ f"received {type(role).__name__}: {role!r}"
+ )
+ if len(set(optimizer_roles)) != len(optimizer_roles):
+ raise ValueError(
+ "trainer runtime identity optimizer_roles must be unique, received "
+ f"{optimizer_roles!r}"
+ )
+ normalized["optimizer_roles"] = optimizer_roles
+ world_size = identity["world_size"]
+ if type(world_size) is not int or world_size < 1:
+ raise TypeError(
+ "trainer runtime identity world_size must be a positive int, "
+ f"received {type(world_size).__name__}: {world_size!r}"
+ )
+ normalized["world_size"] = world_size
+ return normalized
+
+
+def _collect_accelerate_state_files(output_path: str) -> list[dict[str, Any]]:
+ """Record every already-written Accelerate state artifact with integrity data."""
+ entries = []
+ for directory, directory_names, filenames in os.walk(output_path):
+ for directory_name in directory_names:
+ directory_path = os.path.join(directory, directory_name)
+ if os.path.islink(directory_path):
+ raise RuntimeError(
+ "offline state checkpoint staging cannot contain symlinked "
+ f"directories: {directory_path!r}"
+ )
+ for filename in filenames:
+ file_path = os.path.join(directory, filename)
+ if os.path.islink(file_path) or not os.path.isfile(file_path):
+ raise RuntimeError(
+ "offline state checkpoint staging requires regular files, "
+ f"received {file_path!r}"
+ )
+ relative_path = os.path.relpath(file_path, output_path).replace(os.sep, "/")
+ entries.append(_describe_file(file_path, relative_path))
+ return sorted(entries, key=lambda entry: entry["path"])
+
+
+def _describe_file(file_path: str, relative_path: str) -> dict[str, Any]:
+ """Describe one immutable checkpoint artifact without loading it into memory."""
+ return {
+ "path": relative_path,
+ "size": os.path.getsize(file_path),
+ "sha256": _file_sha256(file_path),
+ }
+
+
+def _file_sha256(file_path: str) -> str:
+ """Return the streaming SHA-256 digest for one checkpoint artifact."""
+ digest = hashlib.sha256()
+ with open(file_path, "rb") as artifact:
+ for chunk in iter(lambda: artifact.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _validate_checkpoint_file(input_path: str, entry: Any, *, context: str) -> str:
+ """Validate one manifest entry and its size/digest before state mutation."""
+ entry = _require_mapping(entry, context)
+ _require_exact_keys(entry, _STATE_FILE_KEYS, context)
+ relative_path = entry["path"]
+ if (
+ type(relative_path) is not str
+ or not relative_path
+ or relative_path.startswith("/")
+ or "\\" in relative_path
+ or any(part in ("", ".", "..") for part in relative_path.split("/"))
+ ):
+ raise ValueError(
+ "trainer runtime state file path must be a normalized relative POSIX path, "
+ f"received {relative_path!r}"
+ )
+ expected_size = entry["size"]
+ if type(expected_size) is not int or expected_size < 0:
+ raise TypeError(
+ "trainer runtime state file size must be a non-negative int, "
+ f"received {type(expected_size).__name__}: {expected_size!r}"
+ )
+ expected_sha256 = entry["sha256"]
+ if type(expected_sha256) is not str or not _SHA256_PATTERN.fullmatch(expected_sha256):
+ raise ValueError(
+ "trainer runtime state file sha256 must be a lowercase hexadecimal digest, "
+ f"received {expected_sha256!r}"
+ )
+
+ file_path = os.path.join(input_path, *relative_path.split("/"))
+ if os.path.islink(file_path) or not os.path.isfile(file_path):
+ raise RuntimeError(f"trainer runtime state artifact is missing: expected {file_path!r}")
+ received_size = os.path.getsize(file_path)
+ if received_size != expected_size:
+ raise RuntimeError(
+ "trainer runtime state artifact size mismatch before resume: expected "
+ f"{expected_size} bytes for {relative_path!r}, received {received_size}"
+ )
+ received_sha256 = _file_sha256(file_path)
+ if received_sha256 != expected_sha256:
+ raise RuntimeError(
+ "trainer runtime state artifact SHA-256 mismatch before resume: expected "
+ f"{expected_sha256} for {relative_path!r}, received {received_sha256}"
+ )
+ return relative_path
+
+
+def _validate_accelerate_state_files(
+ input_path: str,
+ entries: Any,
+ *,
+ require_complete: bool,
+) -> None:
+ """Reject missing/truncated core state and parse RNG before model mutation."""
+ if not isinstance(entries, list):
+ raise TypeError(
+ "trainer runtime metadata state_files must be a list, "
+ f"received {type(entries).__name__}: {entries!r}"
+ )
+ received_paths = []
+ for index, entry in enumerate(entries):
+ relative_path = _validate_checkpoint_file(
+ input_path,
+ entry,
+ context=f"trainer runtime state_files[{index}]",
+ )
+ received_paths.append(relative_path)
+
+ if len(set(received_paths)) != len(received_paths):
+ raise ValueError(
+ "trainer runtime metadata state_files contains duplicate paths: "
+ f"{tuple(received_paths)!r}"
+ )
+ if received_paths != sorted(received_paths):
+ raise ValueError("trainer runtime metadata state_files must be sorted by path")
+ if not require_complete:
+ return
+
+ paths = frozenset(received_paths)
+ if not ({"model.safetensors", "pytorch_model.bin"} & paths):
+ raise RuntimeError("offline exact state checkpoint is missing its prepared model artifact")
+ for required_path in ("optimizer.bin", "random_states_0.pkl"):
+ if required_path not in paths:
+ raise RuntimeError(
+ "offline exact state checkpoint is missing required artifact " f"{required_path!r}"
+ )
+
+ rng_path = os.path.join(input_path, "random_states_0.pkl")
+ rng_state = accelerate_load(rng_path, map_location="cpu", weights_only=True)
+ rng_state = _require_mapping(rng_state, "offline RNG state")
+ required_rng_keys = frozenset({"random_state", "numpy_random_seed", "torch_manual_seed"})
+ missing_rng_keys = required_rng_keys.difference(rng_state)
+ if missing_rng_keys:
+ raise ValueError(
+ "offline RNG state is missing required keys: " f"{tuple(sorted(missing_rng_keys))!r}"
+ )
+ if type(rng_state["torch_manual_seed"]) is not torch.Tensor:
+ raise TypeError(
+ "offline RNG torch_manual_seed must be a plain torch.Tensor, received "
+ f"{type(rng_state['torch_manual_seed']).__name__}"
+ )
+
+
+def _validate_metadata_header(
+ metadata: Mapping[str, Any],
+ expected_child_names: tuple[str, ...],
+ expected_identity: Mapping[str, Any],
+) -> None:
+ """Validate the non-executable runtime manifest header."""
+ format_name = metadata["format"]
+ if type(format_name) is not str or format_name != TRAINER_RUNTIME_FORMAT:
+ raise ValueError(
+ "trainer runtime metadata format mismatch: expected "
+ f"{TRAINER_RUNTIME_FORMAT!r}, received {format_name!r}"
+ )
+ version = metadata["version"]
+ if type(version) is not int:
+ raise TypeError(
+ "expected trainer runtime metadata version to be int, "
+ f"received {type(version).__name__}: {version!r}"
+ )
+ if version != TRAINER_RUNTIME_STATE_VERSION:
+ raise ValueError(
+ "trainer runtime metadata version mismatch: expected "
+ f"{TRAINER_RUNTIME_STATE_VERSION}, received {version}"
+ )
+
+ received_identity = _normalize_identity(metadata["identity"])
+ if received_identity != expected_identity:
+ raise ValueError(
+ "trainer runtime metadata identity mismatch: expected "
+ f"{dict(expected_identity)!r}, received {received_identity!r}"
+ )
+
+ child_names = metadata["child_names"]
+ if not isinstance(child_names, list):
+ raise TypeError(
+ "expected trainer runtime metadata child_names as a list, "
+ f"received {type(child_names).__name__}: {child_names!r}"
+ )
+ for name in child_names:
+ _require_child_name(name)
+ if tuple(child_names) != expected_child_names:
+ raise ValueError(
+ "trainer runtime metadata child_names mismatch: expected "
+ f"{expected_child_names!r}, received {tuple(child_names)!r}"
+ )
+
+
+def _reject_duplicate_object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ """Reject duplicate JSON object keys instead of silently accepting the last one."""
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise ValueError(f"trainer runtime JSON contains duplicate key {key!r}")
+ result[key] = value
+ return result
+
+
+def _reject_json_constant(value: str) -> None:
+ """Reject NaN and infinity tokens accepted by Python's JSON decoder."""
+ raise ValueError(f"trainer runtime JSON contains non-finite constant {value!r}")
+
+
+def _require_progress(progress: Any) -> TrainingProgress:
+ """Require the concrete immutable progress value used by execution drivers."""
+ if type(progress) is not TrainingProgress:
+ raise TypeError(
+ "expected progress to be TrainingProgress, "
+ f"received {type(progress).__name__}: {progress!r}"
+ )
+ return progress
+
+
+def _normalize_child_names(child_names: Iterable[str]) -> tuple[str, ...]:
+ """Validate and freeze declared child names."""
+ if isinstance(child_names, (str, bytes)):
+ raise TypeError(
+ "expected child_names to be an iterable of names, "
+ f"received {type(child_names).__name__}: {child_names!r}"
+ )
+ try:
+ names = tuple(child_names)
+ except TypeError as error:
+ raise TypeError(
+ "expected child_names to be an iterable of names, "
+ f"received {type(child_names).__name__}: {child_names!r}"
+ ) from error
+ for name in names:
+ _require_child_name(name)
+ if len(set(names)) != len(names):
+ raise ValueError(f"runtime child names must be unique, received {names!r}")
+ return names
+
+
+def _require_child_name(name: Any) -> None:
+ """Require a non-empty concrete string child name."""
+ if type(name) is not str or not name:
+ raise TypeError(
+ "expected runtime child name to be a non-empty str, "
+ f"received {type(name).__name__}: {name!r}"
+ )
+
+
+def _require_checkpointable_child(
+ child: Any,
+ name: str,
+ *,
+ require_validator: bool = True,
+) -> None:
+ """Require checkpoint save, preflight-validation, and restore methods."""
+ missing = tuple(
+ method_name
+ for method_name in (
+ "state_dict",
+ "load_state_dict",
+ *(("validate_state_dict",) if require_validator else ()),
+ )
+ if not callable(getattr(child, method_name, None))
+ )
+ if missing:
+ raise TypeError(
+ f"runtime child {name!r} must provide callable state_dict/load_state_dict/"
+ "validate_state_dict; "
+ f"missing or non-callable methods: {missing!r}"
+ )
+
+
+def _require_child_payload(payload: Any, name: str) -> Mapping[str, Any]:
+ """Require a mapping payload from or for one child state object."""
+ if not isinstance(payload, Mapping):
+ raise TypeError(
+ f"expected runtime child {name!r} state as a mapping, "
+ f"received {type(payload).__name__}: {payload!r}"
+ )
+ return payload
+
+
+def _require_mapping(value: Any, identifier: str) -> Mapping[str, Any]:
+ """Require a mapping for a serialized state layer."""
+ if not isinstance(value, Mapping):
+ raise TypeError(
+ f"expected {identifier} as a mapping, received {type(value).__name__}: {value!r}"
+ )
+ return value
+
+
+def _require_exact_keys(
+ value: Mapping[str, Any], expected_keys: frozenset[str], identifier: str
+) -> None:
+ """Require concrete string keys and an exact state schema."""
+ non_string_keys = tuple(key for key in value if type(key) is not str)
+ if non_string_keys:
+ raise TypeError(f"expected {identifier} keys to be str, received {non_string_keys!r}")
+ received_keys = frozenset(value)
+ if received_keys != expected_keys:
+ raise ValueError(
+ f"{identifier} keys mismatch: expected {tuple(sorted(expected_keys))!r}, "
+ f"received {tuple(sorted(received_keys))!r}"
+ )
+
+
+def _require_counter(value: Any, name: str) -> int:
+ """Require a non-negative integer progress counter without bool coercion."""
+ if type(value) is not int:
+ raise TypeError(
+ f"expected trainer runtime progress {name} to be int, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ if value < 0:
+ raise ValueError(f"expected trainer runtime progress {name} >= 0, received {value}")
+ return value
+
+
+__all__ = [
+ "CheckpointableChild",
+ "TRAINER_RUNTIME_FORMAT",
+ "TRAINER_RUNTIME_METADATA_FILENAME",
+ "TRAINER_RUNTIME_STATE_VERSION",
+ "TRAINER_RUNTIME_TENSOR_PREFIX",
+ "TrainerRuntimeState",
+]
diff --git a/tests/trainers/test_ema_checkpoint_state.py b/tests/trainers/test_ema_checkpoint_state.py
new file mode 100644
index 000000000..b8c1db53e
--- /dev/null
+++ b/tests/trainers/test_ema_checkpoint_state.py
@@ -0,0 +1,182 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Strict checkpoint contract tests for the real EMA wrapper."""
+
+from copy import deepcopy
+from typing import Any
+
+import pytest
+import torch
+
+from flow_factory.ema.ema import EMA_STATE_VERSION, EMAModuleWrapper
+
+
+def _wrapper() -> EMAModuleWrapper:
+ """Build a deterministic two-parameter EMA wrapper."""
+ parameters = [
+ torch.nn.Parameter(torch.zeros(2, dtype=torch.float32)),
+ torch.nn.Parameter(torch.zeros(1, 3, dtype=torch.float32)),
+ ]
+ return EMAModuleWrapper(
+ parameters,
+ decay=0.9,
+ update_step_interval=2,
+ device=torch.device("cpu"),
+ decay_schedule="linear",
+ initial_decay=0.1,
+ warmup_steps=3,
+ )
+
+
+def _state() -> dict[str, Any]:
+ """Return a valid state with non-default values."""
+ wrapper = _wrapper()
+ wrapper.ema_parameters[0].fill_(4.0)
+ wrapper.ema_parameters[1].fill_(6.0)
+ wrapper.num_updates = 7
+ return wrapper.state_dict()
+
+
+def test_real_ema_wrapper_round_trip_restores_tensors_and_update_count() -> None:
+ """A compatible wrapper restores values without changing its schedule contract."""
+ state = _state()
+ restored = _wrapper()
+
+ restored.load_state_dict(state)
+
+ assert state["version"] == EMA_STATE_VERSION
+ assert restored.num_updates == 7
+ assert restored.decay == pytest.approx(0.9)
+ assert restored.update_step_interval == 2
+ assert restored._decay_schedule == "linear"
+ assert restored._schedule_params == {
+ "initial_decay": 0.1,
+ "warmup_steps": 3,
+ }
+ torch.testing.assert_close(restored.ema_parameters[0], torch.full((2,), 4.0))
+ torch.testing.assert_close(restored.ema_parameters[1], torch.full((1, 3), 6.0))
+ assert restored.ema_parameters[0] is not state["ema_parameters"][0]
+
+
+@pytest.mark.parametrize(
+ ("mutate", "error_type", "match"),
+ [
+ (lambda state: state.pop("version"), ValueError, "state keys mismatch"),
+ (
+ lambda state: state.update(version=True),
+ TypeError,
+ "state version to be int",
+ ),
+ (
+ lambda state: state.update(version=EMA_STATE_VERSION + 1),
+ ValueError,
+ "version mismatch",
+ ),
+ (
+ lambda state: state.update(decay=0.8),
+ ValueError,
+ "decay mismatch",
+ ),
+ (
+ lambda state: state.update(update_step_interval=3),
+ ValueError,
+ "update_step_interval mismatch",
+ ),
+ (
+ lambda state: state.update(decay_schedule="constant"),
+ ValueError,
+ "decay_schedule mismatch",
+ ),
+ (
+ lambda state: state.update(schedule_params={}),
+ ValueError,
+ "schedule_params mismatch",
+ ),
+ (
+ lambda state: state.update(num_updates=-1),
+ ValueError,
+ "num_updates.*non-negative",
+ ),
+ (
+ lambda state: state.update(ema_parameters=state["ema_parameters"][:1]),
+ ValueError,
+ "parameter count mismatch",
+ ),
+ (
+ lambda state: state["ema_parameters"].__setitem__(0, torch.zeros(3)),
+ ValueError,
+ "parameter 0 shape mismatch",
+ ),
+ (
+ lambda state: state["ema_parameters"].__setitem__(
+ 0, state["ema_parameters"][0].to(torch.float64)
+ ),
+ ValueError,
+ "parameter 0 dtype mismatch",
+ ),
+ ],
+)
+def test_real_ema_wrapper_rejects_malformed_state_without_partial_mutation(
+ mutate, error_type, match
+) -> None:
+ """Keys, config, count, shape, and dtype validate before tensor replacement."""
+ state = deepcopy(_state())
+ mutate(state)
+ restored = _wrapper()
+ tensors_before = [parameter.clone() for parameter in restored.ema_parameters]
+
+ with pytest.raises(error_type, match=match):
+ restored.load_state_dict(state)
+
+ assert restored.num_updates == 0
+ for actual, expected in zip(restored.ema_parameters, tensors_before, strict=True):
+ torch.testing.assert_close(actual, expected)
+
+
+def test_ema_state_dict_rejects_tensor_subclasses_without_collective_gather() -> None:
+ """A DTensor-like tensor subclass cannot enter the replicated custom format."""
+
+ class _ShardedTensorLike(torch.Tensor):
+ """Stand in for a distributed tensor subclass."""
+
+ wrapper = _wrapper()
+ wrapper.ema_parameters[0] = wrapper.ema_parameters[0].as_subclass(_ShardedTensorLike)
+
+ with pytest.raises(TypeError, match="replicated plain tensors.*ShardedTensorLike"):
+ wrapper.state_dict()
+
+
+def test_ema_state_dict_rejects_temporary_parameter_swap() -> None:
+ """A state save cannot capture a policy while EMA weights are installed."""
+ wrapper = _wrapper()
+ live_parameters = [
+ torch.nn.Parameter(torch.full_like(parameter, 9.0)) for parameter in wrapper.ema_parameters
+ ]
+
+ with wrapper.use_ema_parameters(live_parameters):
+ with pytest.raises(RuntimeError, match="while EMA parameters are installed"):
+ wrapper.state_dict()
+
+ for parameter in live_parameters:
+ torch.testing.assert_close(parameter, torch.full_like(parameter, 9.0))
+
+
+def test_ema_state_dict_rejects_invalid_live_counter() -> None:
+ """Save-time validation emits only payloads the same wrapper can restore."""
+ wrapper = _wrapper()
+ wrapper.num_updates = -1
+
+ with pytest.raises(ValueError, match="num_updates.*non-negative"):
+ wrapper.state_dict()
diff --git a/tests/trainers/test_runtime_state.py b/tests/trainers/test_runtime_state.py
new file mode 100644
index 000000000..4f3f93628
--- /dev/null
+++ b/tests/trainers/test_runtime_state.py
@@ -0,0 +1,335 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for checkpointable trainer runtime state."""
+
+from collections.abc import Mapping
+from copy import deepcopy
+from dataclasses import FrozenInstanceError
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from flow_factory.trainers.common.runtime_state import (
+ TRAINER_RUNTIME_METADATA_FILENAME,
+ TRAINER_RUNTIME_STATE_VERSION,
+ TRAINER_RUNTIME_TENSOR_PREFIX,
+ TrainerRuntimeState,
+)
+from flow_factory.trainers.execution import TrainingProgress
+
+
+class _RecordingChild:
+ """Record every state restoration while exposing mutable test state."""
+
+ def __init__(self, value: int) -> None:
+ self.value = value
+ self.loads: list[dict[str, Any]] = []
+
+ def state_dict(self) -> dict[str, Any]:
+ """Return the current value."""
+ return {"value": self.value}
+
+ def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Record and restore one value."""
+ payload = dict(state_dict)
+ self.loads.append(payload)
+ self.value = payload["value"]
+
+ def validate_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Accept the narrow value mapping without mutation."""
+ assert set(state_dict) == {"value"}
+
+
+class _FailingChild(_RecordingChild):
+ """Reject restoration to verify pending payload ownership."""
+
+ def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Record one failed attempt without accepting the payload."""
+ self.loads.append(dict(state_dict))
+ raise RuntimeError("child restore failed")
+
+
+class _InvalidChildState:
+ """Expose the required methods but return an invalid state payload."""
+
+ def state_dict(self) -> list[int]:
+ """Return a non-mapping payload."""
+ return [1]
+
+ def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Accept state only to satisfy the structural interface."""
+ del state_dict
+
+ def validate_state_dict(self, state_dict: Mapping[str, Any]) -> None:
+ """Accept state only to satisfy the structural interface."""
+ del state_dict
+
+
+def _valid_state(*, child_names: tuple[str, ...] = ()) -> dict[str, Any]:
+ """Return one valid serialized runtime state for mutation tests."""
+ return {
+ "version": TRAINER_RUNTIME_STATE_VERSION,
+ "progress": {
+ "optimizer_step": 7,
+ "rollout_iteration": 3,
+ "data_epoch": 2,
+ },
+ "children": {name: {"value": index + 10} for index, name in enumerate(child_names)},
+ }
+
+
+def test_state_dict_serializes_concrete_immutable_training_progress() -> None:
+ """Progress remains a frozen value while the runtime replaces whole snapshots."""
+ runtime = TrainerRuntimeState(
+ TrainingProgress(optimizer_step=4, rollout_iteration=2, data_epoch=1)
+ )
+
+ assert runtime.state_dict() == {
+ "version": TRAINER_RUNTIME_STATE_VERSION,
+ "progress": {
+ "optimizer_step": 4,
+ "rollout_iteration": 2,
+ "data_epoch": 1,
+ },
+ "children": {},
+ }
+ with pytest.raises(FrozenInstanceError):
+ runtime.progress.optimizer_step = 5
+
+ replacement = runtime.progress.advance_optimizer_step()
+ runtime.progress = replacement
+ assert runtime.progress == TrainingProgress(
+ optimizer_step=5,
+ rollout_iteration=2,
+ data_epoch=1,
+ )
+
+
+def test_safe_file_round_trip_defers_named_children_until_attachment(
+ tmp_path: Path,
+) -> None:
+ """JSON+safetensors restores progress and each late child payload exactly once."""
+ checkpoint_dir = tmp_path / "state"
+ source = TrainerRuntimeState(
+ TrainingProgress(optimizer_step=8, rollout_iteration=5, data_epoch=3),
+ child_names=("ema", "reference"),
+ )
+ source.attach_child("ema", _RecordingChild(21))
+ source.attach_child("reference", _RecordingChild(34))
+ source.prepare_save(checkpoint_dir)
+
+ restored = TrainerRuntimeState(child_names=("ema", "reference"))
+ restored.validate_load(
+ checkpoint_dir,
+ children={"ema": _RecordingChild(0), "reference": _RecordingChild(0)},
+ )
+
+ assert restored.progress == TrainingProgress()
+ assert restored.validated_load_pending
+ restored.commit_validated_load()
+
+ assert restored.progress == TrainingProgress(
+ optimizer_step=8,
+ rollout_iteration=5,
+ data_epoch=3,
+ )
+ assert restored.pending_child_names == ("ema", "reference")
+
+ reference = _RecordingChild(0)
+ ema = _RecordingChild(0)
+ restored.attach_child("reference", reference)
+ restored.attach_child("ema", ema)
+
+ assert reference.value == 34
+ assert reference.loads == [{"value": 34}]
+ assert ema.value == 21
+ assert ema.loads == [{"value": 21}]
+ assert restored.pending_child_names == ()
+ assert restored.state_dict() == source.state_dict()
+
+ with pytest.raises(RuntimeError, match="already attached"):
+ restored.attach_child("ema", _RecordingChild(0))
+ assert ema.loads == [{"value": 21}]
+
+ assert (checkpoint_dir / TRAINER_RUNTIME_METADATA_FILENAME).is_file()
+ tensor_files = tuple(checkpoint_dir.glob(f"{TRAINER_RUNTIME_TENSOR_PREFIX}.*.safetensors"))
+ assert len(tensor_files) == 1
+ assert not tuple(checkpoint_dir.glob("custom_checkpoint_*.pkl"))
+
+
+def test_failed_child_restore_retains_payload_for_a_successful_attachment() -> None:
+ """A failed child construction cannot consume or silently discard resume state."""
+ runtime = TrainerRuntimeState(child_names=("ema",))
+ runtime.load_state_dict(_valid_state(child_names=("ema",)))
+ failing = _FailingChild(0)
+
+ with pytest.raises(RuntimeError, match="child restore failed"):
+ runtime.attach_child("ema", failing)
+
+ assert failing.loads == [{"value": 10}]
+ assert runtime.pending_child_names == ("ema",)
+ restored = _RecordingChild(0)
+ runtime.attach_child("ema", restored)
+ assert restored.loads == [{"value": 10}]
+ assert runtime.pending_child_names == ()
+
+
+def test_pending_child_payload_remains_serializable_before_construction() -> None:
+ """A save between load and child construction preserves the deferred payload."""
+ payload = _valid_state(child_names=("reference",))
+ runtime = TrainerRuntimeState(child_names=("reference",))
+
+ runtime.load_state_dict(payload)
+
+ assert runtime.state_dict() == payload
+
+
+def test_fresh_state_rejects_missing_or_invalid_child_state() -> None:
+ """Declared children cannot silently disappear from a newly written checkpoint."""
+ missing = TrainerRuntimeState(child_names=("ema",))
+ with pytest.raises(RuntimeError, match="neither attached nor backed"):
+ missing.state_dict()
+
+ invalid = TrainerRuntimeState(child_names=("ema",))
+ invalid.attach_child("ema", _InvalidChildState())
+ with pytest.raises(TypeError, match="child 'ema' state as a mapping"):
+ invalid.state_dict()
+
+
+def test_child_declarations_and_interfaces_fail_fast() -> None:
+ """Names and structural methods are validated before child ownership changes."""
+ with pytest.raises(TypeError, match="child_names.*iterable"):
+ TrainerRuntimeState(child_names="ema")
+ with pytest.raises(TypeError, match="non-empty str"):
+ TrainerRuntimeState(child_names=("",))
+ with pytest.raises(ValueError, match="must be unique"):
+ TrainerRuntimeState(child_names=("ema", "ema"))
+
+ runtime = TrainerRuntimeState(child_names=("ema",))
+ with pytest.raises(KeyError, match="was not declared"):
+ runtime.attach_child("reference", _RecordingChild(0))
+ with pytest.raises(TypeError, match="state_dict/load_state_dict"):
+ runtime.attach_child("ema", object())
+
+
+def test_load_is_single_use_and_must_precede_child_attachment() -> None:
+ """The resume phase cannot replay a payload or overwrite a live child."""
+ loaded = TrainerRuntimeState()
+ loaded.load_state_dict(_valid_state())
+ with pytest.raises(RuntimeError, match="already received"):
+ loaded.load_state_dict(_valid_state())
+
+ attached = TrainerRuntimeState(child_names=("ema",))
+ child = _RecordingChild(1)
+ attached.attach_child("ema", child)
+ with pytest.raises(RuntimeError, match="must load before attaching children"):
+ attached.load_state_dict(_valid_state(child_names=("ema",)))
+ assert child.loads == []
+
+
+@pytest.mark.parametrize(
+ ("mutate", "error_type", "match"),
+ [
+ (lambda state: [], TypeError, "runtime state as a mapping"),
+ (
+ lambda state: {key: value for key, value in state.items() if key != "children"},
+ ValueError,
+ "runtime state keys mismatch",
+ ),
+ (
+ lambda state: {**state, "unexpected": None},
+ ValueError,
+ "runtime state keys mismatch",
+ ),
+ (
+ lambda state: {**state, "version": True},
+ TypeError,
+ "state version to be int",
+ ),
+ (
+ lambda state: {**state, "version": TRAINER_RUNTIME_STATE_VERSION + 1},
+ ValueError,
+ "version mismatch",
+ ),
+ (
+ lambda state: {**state, "progress": []},
+ TypeError,
+ "runtime progress as a mapping",
+ ),
+ (
+ lambda state: {
+ **state,
+ "progress": {
+ key: value for key, value in state["progress"].items() if key != "data_epoch"
+ },
+ },
+ ValueError,
+ "runtime progress keys mismatch",
+ ),
+ (
+ lambda state: {
+ **state,
+ "progress": {**state["progress"], "optimizer_step": False},
+ },
+ TypeError,
+ "optimizer_step to be int",
+ ),
+ (
+ lambda state: {
+ **state,
+ "progress": {**state["progress"], "data_epoch": -1},
+ },
+ ValueError,
+ "data_epoch >= 0",
+ ),
+ (
+ lambda state: {**state, "children": []},
+ TypeError,
+ "runtime children as a mapping",
+ ),
+ (
+ lambda state: {**state, "children": {}},
+ ValueError,
+ "runtime children keys mismatch",
+ ),
+ (
+ lambda state: {**state, "children": {"ema": []}},
+ TypeError,
+ "child 'ema' state as a mapping",
+ ),
+ ],
+)
+def test_load_rejects_malformed_schema_without_mutating_progress(mutate, error_type, match) -> None:
+ """Every serialized layer is validated before restored progress becomes visible."""
+ runtime = TrainerRuntimeState(
+ TrainingProgress(optimizer_step=1),
+ child_names=("ema",),
+ )
+ state = mutate(deepcopy(_valid_state(child_names=("ema",))))
+
+ with pytest.raises(error_type, match=match):
+ runtime.load_state_dict(state)
+
+ assert runtime.progress == TrainingProgress(optimizer_step=1)
+ assert runtime.pending_child_names == ()
+
+
+def test_progress_assignment_requires_the_concrete_training_progress_type() -> None:
+ """Mutable mappings and lookalike values cannot replace immutable progress."""
+ runtime = TrainerRuntimeState()
+
+ with pytest.raises(TypeError, match="progress to be TrainingProgress"):
+ runtime.progress = {"optimizer_step": 1} # type: ignore[assignment]
From 12eded546e860436e79747d0a2875dc05f2db84b Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:24:29 +0800
Subject: [PATCH 10/76] feat(hparams): add offline training arguments
---
src/flow_factory/hparams/__init__.py | 4 +
.../hparams/training_args/__init__.py | 4 +
.../hparams/training_args/_offline.py | 179 +++++++++++++
.../hparams/training_args/_registry.py | 4 +
.../hparams/training_args/offline_dpo.py | 87 ++++++
src/flow_factory/hparams/training_args/sft.py | 52 ++++
tests/hparams/test_offline_training_args.py | 250 ++++++++++++++++++
7 files changed, 580 insertions(+)
create mode 100644 src/flow_factory/hparams/training_args/_offline.py
create mode 100644 src/flow_factory/hparams/training_args/offline_dpo.py
create mode 100644 src/flow_factory/hparams/training_args/sft.py
create mode 100644 tests/hparams/test_offline_training_args.py
diff --git a/src/flow_factory/hparams/__init__.py b/src/flow_factory/hparams/__init__.py
index 83cf09f11..0cb9e26c6 100644
--- a/src/flow_factory/hparams/__init__.py
+++ b/src/flow_factory/hparams/__init__.py
@@ -42,6 +42,8 @@
DPPOTrainingArguments,
GRPOTrainingArguments,
NFTTrainingArguments,
+ OfflineDPOTrainingArguments,
+ SFTTrainingArguments,
TDMR1TrainingArguments,
TDMTrainingArguments,
TeacherConfig,
@@ -68,6 +70,8 @@
"DPOTrainingArguments",
"CRDTrainingArguments",
"DiffusionOPDTrainingArguments",
+ "SFTTrainingArguments",
+ "OfflineDPOTrainingArguments",
"TeacherConfig",
"get_training_args_class",
"RewardArguments",
diff --git a/src/flow_factory/hparams/training_args/__init__.py b/src/flow_factory/hparams/training_args/__init__.py
index 64cce0e5d..cec2f6670 100644
--- a/src/flow_factory/hparams/training_args/__init__.py
+++ b/src/flow_factory/hparams/training_args/__init__.py
@@ -31,7 +31,9 @@
from .dppo import DPPOTrainingArguments
from .grpo import GRPOTrainingArguments
from .nft import NFTTrainingArguments
+from .offline_dpo import OfflineDPOTrainingArguments
from .opd import DiffusionOPDTrainingArguments, TeacherConfig
+from .sft import SFTTrainingArguments
from .tdm import TDMTrainingArguments
from .tdm_r1 import TDMR1TrainingArguments
from ..gradient_checkpointing import (
@@ -55,6 +57,8 @@
"TDMR1TrainingArguments",
"CRDTrainingArguments",
"DiffusionOPDTrainingArguments",
+ "SFTTrainingArguments",
+ "OfflineDPOTrainingArguments",
"TeacherConfig",
"get_training_args_class",
"list_registered_training_args",
diff --git a/src/flow_factory/hparams/training_args/_offline.py b/src/flow_factory/hparams/training_args/_offline.py
new file mode 100644
index 000000000..080c3777e
--- /dev/null
+++ b/src/flow_factory/hparams/training_args/_offline.py
@@ -0,0 +1,179 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Shared training arguments for finite offline flow-matching objectives."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, field
+from numbers import Real
+from typing import Literal, Tuple, Union
+
+from ._base import TrainingArguments
+
+
+@dataclass
+class OfflineFlowMatchingTrainingArguments(TrainingArguments):
+ """Configure model-agnostic flow matching over a finite offline loader."""
+
+ max_epochs: int = field(
+ default=1,
+ metadata={
+ "help": (
+ "Number of complete offline dataloader traversals. One successful traversal "
+ "is one data epoch; partial traversals do not advance this counter."
+ )
+ },
+ )
+ gradient_accumulation_steps: int = field(
+ default=1,
+ metadata={
+ "help": (
+ "Explicit number of offline dataloader microbatches per optimizer step. "
+ "Offline training does not derive this value from grouped rollout geometry."
+ )
+ },
+ )
+ weighting_scheme: Literal["logit_normal", "uniform"] = field(
+ default="logit_normal",
+ metadata={"help": "Distribution used to sample independent flow-matching timesteps."},
+ )
+ num_train_timesteps: int = field(
+ default=1,
+ metadata={
+ "help": (
+ "Number of independently sampled Monte Carlo timestep terms averaged per "
+ "offline example. This value does not multiply gradient accumulation."
+ )
+ },
+ )
+ timestep_range: Union[float, Tuple[float, float]] = field(
+ default=0.99,
+ metadata={
+ "help": (
+ "Fraction range along the denoising axis from scheduler time 1000 to 0. "
+ "A scalar upper bound is normalized to (0, upper)."
+ )
+ },
+ )
+ time_shift: float = field(
+ default=1.0,
+ metadata={"help": "Positive rational shift applied to sampled timestep fractions."},
+ )
+ logit_mean: float = field(
+ default=0.0,
+ metadata={"help": "Finite mean of the logit-normal timestep distribution."},
+ )
+ logit_std: float = field(
+ default=1.0,
+ metadata={"help": "Positive standard deviation of the logit-normal distribution."},
+ )
+
+ def __post_init__(self) -> None:
+ """Normalize and validate finite offline optimization controls."""
+ super().__post_init__()
+ self.max_epochs = _positive_int(self.max_epochs, "train.max_epochs")
+ self.num_train_timesteps = _positive_int(
+ self.num_train_timesteps,
+ "train.num_train_timesteps",
+ )
+ if not isinstance(self.weighting_scheme, str):
+ raise TypeError(
+ "expected train.weighting_scheme as a string, received "
+ f"{type(self.weighting_scheme).__name__}: {self.weighting_scheme!r}"
+ )
+ if self.weighting_scheme not in ("logit_normal", "uniform"):
+ raise ValueError(
+ "expected train.weighting_scheme to be 'logit_normal' or 'uniform', "
+ f"received {self.weighting_scheme!r}"
+ )
+ self.timestep_range = _timestep_range(self.timestep_range)
+ self.time_shift = _finite_float(
+ self.time_shift,
+ "train.time_shift",
+ strictly_positive=True,
+ )
+ self.logit_mean = _finite_float(
+ self.logit_mean,
+ "train.logit_mean",
+ strictly_positive=False,
+ )
+ self.logit_std = _finite_float(
+ self.logit_std,
+ "train.logit_std",
+ strictly_positive=True,
+ )
+
+
+def _positive_int(value: object, field_name: str) -> int:
+ """Require one positive integer without accepting booleans."""
+ if type(value) is not int:
+ raise TypeError(
+ f"expected {field_name} as an int >= 1, received " f"{type(value).__name__}: {value!r}"
+ )
+ if value < 1:
+ raise ValueError(f"expected {field_name} >= 1, received {value}")
+ return value
+
+
+def _finite_float(value: object, field_name: str, *, strictly_positive: bool) -> float:
+ """Require one finite real scalar and optionally require positivity."""
+ if isinstance(value, bool) or not isinstance(value, Real):
+ raise TypeError(
+ f"expected {field_name} as a finite real number, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+ converted = float(value)
+ if not math.isfinite(converted):
+ raise ValueError(f"expected finite {field_name}, received {value!r}")
+ if strictly_positive and converted <= 0:
+ raise ValueError(f"expected {field_name} > 0, received {value!r}")
+ return converted
+
+
+def _timestep_range(value: object) -> Tuple[float, float]:
+ """Normalize one strict denoising-axis fraction range."""
+ if isinstance(value, (tuple, list)):
+ if len(value) != 2:
+ raise ValueError(
+ "expected train.timestep_range as a scalar or two-item sequence, "
+ f"received {value!r}"
+ )
+ lower = _finite_float(
+ value[0],
+ "train.timestep_range[0]",
+ strictly_positive=False,
+ )
+ upper = _finite_float(
+ value[1],
+ "train.timestep_range[1]",
+ strictly_positive=False,
+ )
+ else:
+ lower = 0.0
+ upper = _finite_float(
+ value,
+ "train.timestep_range",
+ strictly_positive=False,
+ )
+ if not 0.0 <= lower < upper <= 1.0:
+ raise ValueError(
+ "expected train.timestep_range to satisfy 0 <= lower < upper <= 1, "
+ f"received {(lower, upper)!r}"
+ )
+ return lower, upper
+
+
+__all__ = ["OfflineFlowMatchingTrainingArguments"]
diff --git a/src/flow_factory/hparams/training_args/_registry.py b/src/flow_factory/hparams/training_args/_registry.py
index d6d45f754..b9217e650 100644
--- a/src/flow_factory/hparams/training_args/_registry.py
+++ b/src/flow_factory/hparams/training_args/_registry.py
@@ -28,7 +28,9 @@
from .dppo import DPPOTrainingArguments
from .grpo import GRPOTrainingArguments
from .nft import NFTTrainingArguments
+from .offline_dpo import OfflineDPOTrainingArguments
from .opd import DiffusionOPDTrainingArguments
+from .sft import SFTTrainingArguments
from .tdm import TDMTrainingArguments
from .tdm_r1 import TDMR1TrainingArguments
@@ -49,6 +51,8 @@
"tdm": TDMTrainingArguments,
"tdm-r1": TDMR1TrainingArguments,
"diffusion-opd": DiffusionOPDTrainingArguments,
+ "sft": SFTTrainingArguments,
+ "offline-dpo": OfflineDPOTrainingArguments,
}
diff --git a/src/flow_factory/hparams/training_args/offline_dpo.py b/src/flow_factory/hparams/training_args/offline_dpo.py
new file mode 100644
index 000000000..987c6b95c
--- /dev/null
+++ b/src/flow_factory/hparams/training_args/offline_dpo.py
@@ -0,0 +1,87 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Training arguments for finite-dataset diffusion DPO."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, ClassVar, Literal, Mapping
+
+from ...contracts.execution import OFFLINE_EXECUTION_CONTRACT, ExecutionContract
+from ._offline import OfflineFlowMatchingTrainingArguments, _finite_float
+
+
+@dataclass
+class OfflineDPOTrainingArguments(OfflineFlowMatchingTrainingArguments):
+ """Configure reference-based DPO over an offline preference dataset."""
+
+ execution_contract: ClassVar[ExecutionContract] = OFFLINE_EXECUTION_CONTRACT
+
+ trainer_type: Literal["offline-dpo"] = field(
+ default="offline-dpo",
+ metadata={"help": "Select the offline preference DPO trainer."},
+ )
+ beta: float = field(
+ default=2000.0,
+ metadata={
+ "help": (
+ "Positive DPO temperature multiplying the policy-versus-reference "
+ "chosen/rejected flow-matching loss delta."
+ )
+ },
+ )
+
+ def __post_init__(self) -> None:
+ """Validate the fixed trainer identity and reference-based DPO scale."""
+ super().__post_init__()
+ if self.trainer_type != "offline-dpo":
+ raise ValueError(
+ "OfflineDPOTrainingArguments requires train.trainer_type='offline-dpo', "
+ f"received {self.trainer_type!r}"
+ )
+ self.beta = _finite_float(
+ self.beta,
+ "train.beta",
+ strictly_positive=True,
+ )
+
+ @classmethod
+ def from_dict(cls, args_dict: Mapping[str, Any]) -> "OfflineDPOTrainingArguments":
+ """Parse only the reference-based semantics implemented by the DPO objective.
+
+ Args:
+ args_dict: User training configuration.
+
+ Returns:
+ Reference-based offline-DPO arguments.
+ """
+ explicit_extras = args_dict.get("extra_kwargs") if isinstance(args_dict, Mapping) else None
+ if isinstance(args_dict, Mapping) and (
+ "reference_free" in args_dict
+ or (isinstance(explicit_extras, Mapping) and "reference_free" in explicit_extras)
+ ):
+ raise ValueError(
+ "offline-dpo currently requires frozen reference losses; "
+ "train.reference_free is not implemented"
+ )
+ return super().from_dict(args_dict)
+
+ @property
+ def requires_ref_model(self) -> bool:
+ """Return true because the shared DPO objective consumes reference losses."""
+ return True
+
+
+__all__ = ["OfflineDPOTrainingArguments"]
diff --git a/src/flow_factory/hparams/training_args/sft.py b/src/flow_factory/hparams/training_args/sft.py
new file mode 100644
index 000000000..6aef36caf
--- /dev/null
+++ b/src/flow_factory/hparams/training_args/sft.py
@@ -0,0 +1,52 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Training arguments for supervised flow-matching fine-tuning."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import ClassVar, Literal
+
+from ...contracts.execution import OFFLINE_EXECUTION_CONTRACT, ExecutionContract
+from ._offline import OfflineFlowMatchingTrainingArguments
+
+
+@dataclass
+class SFTTrainingArguments(OfflineFlowMatchingTrainingArguments):
+ """Configure finite-dataset supervised flow-matching training."""
+
+ execution_contract: ClassVar[ExecutionContract] = OFFLINE_EXECUTION_CONTRACT
+
+ trainer_type: Literal["sft"] = field(
+ default="sft",
+ metadata={"help": "Select the offline supervised fine-tuning trainer."},
+ )
+
+ def __post_init__(self) -> None:
+ """Validate the fixed SFT trainer identity."""
+ super().__post_init__()
+ if self.trainer_type != "sft":
+ raise ValueError(
+ "SFTTrainingArguments requires train.trainer_type='sft', "
+ f"received {self.trainer_type!r}"
+ )
+
+ @property
+ def requires_ref_model(self) -> bool:
+ """Return false because supervised flow matching has no reference branch."""
+ return False
+
+
+__all__ = ["SFTTrainingArguments"]
diff --git a/tests/hparams/test_offline_training_args.py b/tests/hparams/test_offline_training_args.py
new file mode 100644
index 000000000..e2b5e37e4
--- /dev/null
+++ b/tests/hparams/test_offline_training_args.py
@@ -0,0 +1,250 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for SFT and offline-DPO training argument contracts."""
+
+from dataclasses import fields
+
+import pytest
+
+from flow_factory.contracts.execution import OFFLINE_EXECUTION_CONTRACT
+from flow_factory.hparams import (
+ Arguments,
+ OfflineDPOTrainingArguments,
+ SFTTrainingArguments,
+ get_training_args_class,
+)
+
+
+def _offline_config(trainer_type: str, **train_overrides: object) -> Arguments:
+ train = {
+ "trainer_type": trainer_type,
+ "gradient_accumulation_steps": 2,
+ "max_epochs": 3,
+ **train_overrides,
+ }
+ return Arguments.from_dict(
+ {
+ "data": {
+ "datasets": [
+ {
+ "name": "offline",
+ "dataset_dir": "unused",
+ "train": {"weight": 1},
+ }
+ ]
+ },
+ "scheduler": {"dynamics_type": "ODE"},
+ "train": train,
+ }
+ )
+
+
+@pytest.mark.parametrize(
+ ("trainer_type", "arguments_class"),
+ [
+ ("sft", SFTTrainingArguments),
+ ("offline-dpo", OfflineDPOTrainingArguments),
+ ],
+)
+def test_offline_training_arguments_resolve_from_public_config(
+ trainer_type: str,
+ arguments_class: type,
+) -> None:
+ """Resolve both offline algorithms without entering grouped rollout geometry."""
+ config = _offline_config(
+ trainer_type,
+ weighting_scheme="uniform",
+ num_train_timesteps=3,
+ timestep_range=[0.1, 0.9],
+ time_shift=2,
+ logit_mean=-0.5,
+ logit_std=1.5,
+ )
+
+ assert get_training_args_class(trainer_type) is arguments_class
+ assert isinstance(config.training_args, arguments_class)
+ assert config.training_args.execution_contract is OFFLINE_EXECUTION_CONTRACT
+ assert config.training_args.gradient_accumulation_steps == 2
+ assert config.training_args.max_epochs == 3
+ assert config.training_args.num_batches_per_epoch == 0
+ assert config.training_args.num_train_timesteps == 3
+ assert config.training_args.timestep_range == (0.1, 0.9)
+ assert config.training_args.time_shift == 2.0
+ assert config.data_args.sampler_type == "auto"
+
+
+def test_offline_defaults_express_data_epoch_and_reference_semantics() -> None:
+ """Keep SFT reference-free while DPO explicitly requires frozen reference losses."""
+ sft = SFTTrainingArguments()
+ dpo = OfflineDPOTrainingArguments()
+
+ assert sft.trainer_type == "sft"
+ assert dpo.trainer_type == "offline-dpo"
+ assert sft.max_epochs == dpo.max_epochs == 1
+ assert sft.gradient_accumulation_steps == dpo.gradient_accumulation_steps == 1
+ assert sft.requires_ref_model is False
+ assert dpo.requires_ref_model is True
+ assert dpo.beta == 2000.0
+ assert "reference_free" not in {field.name for field in fields(dpo)}
+ assert "execution_contract" not in dpo.to_dict()
+
+
+@pytest.mark.parametrize("arguments_class", [SFTTrainingArguments, OfflineDPOTrainingArguments])
+@pytest.mark.parametrize("value", ["auto", 0, -1, True, 1.5])
+def test_offline_gradient_accumulation_must_be_an_explicit_positive_integer(
+ arguments_class: type,
+ value: object,
+) -> None:
+ """Reject online automatic accumulation and ambiguous numeric values."""
+ with pytest.raises((TypeError, ValueError), match="gradient_accumulation_steps"):
+ arguments_class(gradient_accumulation_steps=value)
+
+
+@pytest.mark.parametrize("arguments_class", [SFTTrainingArguments, OfflineDPOTrainingArguments])
+@pytest.mark.parametrize("value", [None, 0, -1, True, 1.5])
+def test_offline_max_epochs_counts_positive_complete_loader_traversals(
+ arguments_class: type,
+ value: object,
+) -> None:
+ """Require a finite positive count of complete offline data epochs."""
+ with pytest.raises((TypeError, ValueError), match="train.max_epochs"):
+ arguments_class(max_epochs=value)
+
+
+@pytest.mark.parametrize("arguments_class", [SFTTrainingArguments, OfflineDPOTrainingArguments])
+@pytest.mark.parametrize("value", [0, -1, True, 1.5])
+def test_offline_num_train_timesteps_is_a_positive_monte_carlo_count(
+ arguments_class: type,
+ value: object,
+) -> None:
+ """Reject invalid independent timestep-term counts."""
+ with pytest.raises((TypeError, ValueError), match="train.num_train_timesteps"):
+ arguments_class(num_train_timesteps=value)
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "0.9",
+ [0.1],
+ [0.1, 0.5, 0.9],
+ [0.7, 0.2],
+ [-0.1, 0.9],
+ [0.1, 1.1],
+ [0.1, float("nan")],
+ [False, 0.9],
+ ],
+)
+def test_offline_timestep_range_uses_strict_denoising_axis_fractions(value: object) -> None:
+ """Reject malformed, non-finite, and out-of-domain timestep fractions."""
+ with pytest.raises((TypeError, ValueError), match="train.timestep_range"):
+ SFTTrainingArguments(timestep_range=value)
+
+
+@pytest.mark.parametrize(
+ ("field_name", "value"),
+ [
+ ("time_shift", 0),
+ ("time_shift", float("inf")),
+ ("time_shift", True),
+ ("logit_mean", float("nan")),
+ ("logit_mean", "0"),
+ ("logit_std", 0),
+ ("logit_std", -1),
+ ("logit_std", float("inf")),
+ ],
+)
+def test_offline_distribution_parameters_are_finite_and_well_defined(
+ field_name: str,
+ value: object,
+) -> None:
+ """Validate every scalar used by the independent timestep sampler."""
+ with pytest.raises((TypeError, ValueError), match=f"train.{field_name}"):
+ SFTTrainingArguments(**{field_name: value})
+
+
+@pytest.mark.parametrize("value", ["discrete", ["uniform"]])
+def test_offline_weighting_scheme_rejects_online_or_discrete_modes(value: object) -> None:
+ """Limit the public configuration to implemented offline samplers."""
+ with pytest.raises((TypeError, ValueError), match="train.weighting_scheme"):
+ SFTTrainingArguments(weighting_scheme=value)
+
+
+@pytest.mark.parametrize("value", [0, -1, True, float("nan"), float("inf")])
+def test_offline_dpo_requires_a_positive_finite_beta(value: object) -> None:
+ """Reject scales that cannot represent the implemented DPO temperature."""
+ with pytest.raises((TypeError, ValueError), match="train.beta"):
+ OfflineDPOTrainingArguments(beta=value)
+
+
+@pytest.mark.parametrize(
+ "values",
+ [
+ {"reference_free": True},
+ {"extra_kwargs": {"reference_free": False}},
+ ],
+)
+def test_offline_dpo_rejects_unimplemented_reference_free_configuration(
+ values: dict,
+) -> None:
+ """Keep the public surface aligned with the four-input shared DPO objective."""
+ with pytest.raises(ValueError, match="requires frozen reference losses"):
+ OfflineDPOTrainingArguments.from_dict(values)
+
+
+@pytest.mark.parametrize("arguments_class", [SFTTrainingArguments, OfflineDPOTrainingArguments])
+def test_user_cannot_override_offline_execution_contract(arguments_class: type) -> None:
+ """Select offline acquisition only through the registered trainer type."""
+ with pytest.raises(ValueError, match="selected by trainer_type"):
+ arguments_class.from_dict({"execution_contract": "generation"})
+
+
+@pytest.mark.parametrize(
+ ("arguments_class", "wrong_trainer_type"),
+ [
+ (SFTTrainingArguments, "offline-dpo"),
+ (OfflineDPOTrainingArguments, "sft"),
+ ],
+)
+def test_direct_construction_rejects_mismatched_offline_trainer_identity(
+ arguments_class: type,
+ wrong_trainer_type: str,
+) -> None:
+ """Prevent direct class use from disagreeing with registry dispatch."""
+ with pytest.raises(ValueError, match="requires train.trainer_type"):
+ arguments_class(trainer_type=wrong_trainer_type)
+
+
+def test_offline_config_rejects_runtime_training_rewards() -> None:
+ """Keep dataset supervision independent from online reward feedback."""
+ with pytest.raises(ValueError, match="does not accept training rewards"):
+ Arguments.from_dict(
+ {
+ "data": {
+ "datasets": [
+ {
+ "name": "offline",
+ "dataset_dir": "unused",
+ "train": {"weight": 1},
+ }
+ ]
+ },
+ "train": {
+ "trainer_type": "sft",
+ "gradient_accumulation_steps": 1,
+ },
+ "rewards": [{"name": "score", "reward_model": "clip"}],
+ }
+ )
From 4c5030ae59bfb46512a46ecc0b80f6b010971bdf Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:25:47 +0800
Subject: [PATCH 11/76] feat(training): add SFT and offline DPO trainers
---
.../trainers/common/flow_matching.py | 64 +++
.../trainers/common/forward_kwargs.py | 10 +-
src/flow_factory/trainers/forward_process.py | 5 +-
src/flow_factory/trainers/offline/__init__.py | 6 +
.../trainers/offline/offline_dpo.py | 221 ++++++++++
src/flow_factory/trainers/offline/sft.py | 145 ++++++
src/flow_factory/trainers/registry.py | 2 +
tests/trainers/test_offline_flow_matching.py | 22 +
tests/trainers/test_offline_trainers.py | 417 ++++++++++++++++++
9 files changed, 884 insertions(+), 8 deletions(-)
create mode 100644 src/flow_factory/trainers/offline/__init__.py
create mode 100644 src/flow_factory/trainers/offline/offline_dpo.py
create mode 100644 src/flow_factory/trainers/offline/sft.py
create mode 100644 tests/trainers/test_offline_trainers.py
diff --git a/src/flow_factory/trainers/common/flow_matching.py b/src/flow_factory/trainers/common/flow_matching.py
index f20150915..236b9dea7 100644
--- a/src/flow_factory/trainers/common/flow_matching.py
+++ b/src/flow_factory/trainers/common/flow_matching.py
@@ -235,6 +235,69 @@ def validate_preference_output_states(
)
+def validate_preference_component_times(
+ chosen: ComponentTimes,
+ rejected: ComponentTimes,
+) -> None:
+ """Require pairwise arms to resolve one identical forward-process schedule.
+
+ Offline DPO supplies the same primary scheduler coordinates to both output
+ arms. An adapter may still derive component-specific coordinates from output
+ context, so equality has to be proven after that model-owned mapping rather
+ than inferred from the shared primary tensor.
+
+ Args:
+ chosen: Component schedule resolved for the chosen output.
+ rejected: Component schedule resolved for the rejected output.
+
+ Raises:
+ TypeError: If either value is not ``ComponentTimes`` or optional fields differ.
+ ValueError: If field metadata, component order, or tensor values differ.
+ """
+ for name, value in (("chosen", chosen), ("rejected", rejected)):
+ if not isinstance(value, ComponentTimes):
+ raise TypeError(
+ f"expected {name} component times to be ComponentTimes, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+
+ for field_name in ("timestep", "next_timestep", "sigma", "next_sigma"):
+ chosen_values = getattr(chosen, field_name)
+ rejected_values = getattr(rejected, field_name)
+ if (chosen_values is None) != (rejected_values is None):
+ raise TypeError(
+ "preference arm component times optional-field mismatch for " f"{field_name!r}"
+ )
+ if chosen_values is None:
+ continue
+ if tuple(chosen_values) != tuple(rejected_values):
+ raise ValueError(
+ "preference arm component times order mismatch for "
+ f"{field_name!r}: chosen={tuple(chosen_values)}, "
+ f"rejected={tuple(rejected_values)}"
+ )
+ for component_name in chosen_values:
+ chosen_tensor = chosen_values[component_name]
+ rejected_tensor = rejected_values[component_name]
+ if (
+ chosen_tensor.shape != rejected_tensor.shape
+ or chosen_tensor.dtype != rejected_tensor.dtype
+ or chosen_tensor.device != rejected_tensor.device
+ ):
+ raise ValueError(
+ "preference arm component times tensor metadata mismatch for "
+ f"{field_name}[{component_name!r}]: "
+ f"chosen=({tuple(chosen_tensor.shape)}, {chosen_tensor.dtype}, "
+ f"{chosen_tensor.device}), rejected=({tuple(rejected_tensor.shape)}, "
+ f"{rejected_tensor.dtype}, {rejected_tensor.device})"
+ )
+ if not torch.equal(chosen_tensor, rejected_tensor):
+ raise ValueError(
+ "preference arm component times values mismatch for "
+ f"{field_name}[{component_name!r}]"
+ )
+
+
def _validate_matching_masks(chosen: LatentState, rejected: LatentState) -> None:
if (chosen.active_masks is None) != (rejected.active_masks is None):
raise ValueError("preference arms must either both define active masks or both omit them")
@@ -316,5 +379,6 @@ def _require_positive_int(value: object, identifier: str) -> None:
"build_noised_output_state",
"flow_matching_per_sample_loss",
"sample_offline_timesteps",
+ "validate_preference_component_times",
"validate_preference_output_states",
]
diff --git a/src/flow_factory/trainers/common/forward_kwargs.py b/src/flow_factory/trainers/common/forward_kwargs.py
index 5017b36e6..74e0a5372 100644
--- a/src/flow_factory/trainers/common/forward_kwargs.py
+++ b/src/flow_factory/trainers/common/forward_kwargs.py
@@ -3,12 +3,10 @@
from collections.abc import Mapping
from typing import Any
-from ...samples import StackedSampleBatch
-
def _batch_preferred_kwargs(
configured: Mapping[str, Any],
- batch: StackedSampleBatch,
+ batch: Mapping[str, Any],
) -> dict[str, Any]:
"""Return configured values for keys not already carried by ``batch``.
@@ -19,19 +17,19 @@ def _batch_preferred_kwargs(
return {key: value for key, value in configured.items() if key not in batch}
-def training_forward_kwargs(trainer: Any, batch: StackedSampleBatch) -> dict[str, Any]:
+def training_forward_kwargs(trainer: Any, batch: Mapping[str, Any]) -> dict[str, Any]:
"""Return training defaults while preserving batch-key precedence."""
return _batch_preferred_kwargs({**trainer.training_args}, batch)
-def replay_forward_kwargs(trainer: Any, batch: StackedSampleBatch) -> dict[str, Any]:
+def replay_forward_kwargs(trainer: Any, batch: Mapping[str, Any]) -> dict[str, Any]:
"""Return replay defaults while preserving batch-key precedence."""
return training_forward_kwargs(trainer, batch)
def reference_forward_kwargs(
trainer: Any,
- batch: StackedSampleBatch,
+ batch: Mapping[str, Any],
**overrides: Any,
) -> dict[str, Any]:
"""Return replay defaults with explicit reference-pass overrides."""
diff --git a/src/flow_factory/trainers/forward_process.py b/src/flow_factory/trainers/forward_process.py
index 2c5df7a00..d28985ed8 100644
--- a/src/flow_factory/trainers/forward_process.py
+++ b/src/flow_factory/trainers/forward_process.py
@@ -20,9 +20,10 @@
forward contract rather than the coupled replay contract in ``grpo.py``.
"""
+from collections.abc import Mapping
from typing import Any
-from ..samples import ComponentTimes, LatentState, StackedSampleBatch
+from ..samples import ComponentTimes, LatentState
from .common.forward_kwargs import training_forward_kwargs
from .common.state_validation import (
require_component_sigmas,
@@ -34,7 +35,7 @@
def forward_velocity_state(
trainer: Any,
- batch: StackedSampleBatch,
+ batch: Mapping[str, Any],
state: LatentState,
times: ComponentTimes,
*,
diff --git a/src/flow_factory/trainers/offline/__init__.py b/src/flow_factory/trainers/offline/__init__.py
new file mode 100644
index 000000000..13c2d58e4
--- /dev/null
+++ b/src/flow_factory/trainers/offline/__init__.py
@@ -0,0 +1,6 @@
+"""Finite-dataset training algorithms."""
+
+from .offline_dpo import OfflineDPOTrainer
+from .sft import SFTTrainer
+
+__all__ = ["OfflineDPOTrainer", "SFTTrainer"]
diff --git a/src/flow_factory/trainers/offline/offline_dpo.py b/src/flow_factory/trainers/offline/offline_dpo.py
new file mode 100644
index 000000000..34d5f8efe
--- /dev/null
+++ b/src/flow_factory/trainers/offline/offline_dpo.py
@@ -0,0 +1,221 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Finite-dataset diffusion DPO with on-the-fly output encoding."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import Any, ClassVar, Dict, List, Literal, Tuple
+
+import torch
+from torch.utils.data import DataLoader
+
+from ...contracts import OFFLINE_EXECUTION_CONTRACT
+from ...data_utils.offline_dataset import OfflineBatch, PreferenceOutputBatch
+from ...data_utils.offline_train_data import build_offline_train_dataloader
+from ..abc import BaseTrainer
+from ..common.dpo_objective import dpo_objective
+from ..common.flow_matching import (
+ build_noised_output_state,
+ flow_matching_per_sample_loss,
+ sample_offline_timesteps,
+ validate_preference_component_times,
+ validate_preference_output_states,
+)
+from ..common.offline_batch import bind_output_forward_context, move_condition_to_device
+from ..forward_process import forward_velocity_state
+
+MetricAccumulator = Dict[str, List[torch.Tensor]]
+
+
+class OfflineDPOTrainer(BaseTrainer):
+ """Optimize chosen/rejected dataset pairs against a frozen reference policy."""
+
+ paradigm: ClassVar[Literal["decoupled"]] = "decoupled"
+ execution_contract = OFFLINE_EXECUTION_CONTRACT
+
+ def _build_train_dataloader(self) -> Tuple[DataLoader, Dict[str, DataLoader]]:
+ """Build the finite preference loader without Accelerator reshaping it."""
+ dataloader = build_offline_train_dataloader(
+ config=self.config,
+ accelerator=self.accelerator,
+ preprocess_func=self.adapter.preprocess_func,
+ supervision_type="preference",
+ pipeline_io_contract=self.adapter.pipeline_io_contract,
+ )
+ return dataloader, {}
+
+ def optimize_batch(self, batch: Any) -> None:
+ """Apply one gradient-accumulation microstep from a preference batch."""
+ preference = _require_preference_batch(batch)
+ self.adapter.train()
+
+ condition = move_condition_to_device(batch.condition, self.accelerator.device)
+ chosen = self.adapter.encode_output_state(preference.chosen_media, condition)
+ rejected = self.adapter.encode_output_state(preference.rejected_media, condition)
+ validate_preference_output_states(chosen, rejected)
+
+ chosen_batch = bind_output_forward_context(condition, chosen.forward_context)
+ rejected_batch = bind_output_forward_context(condition, rejected.forward_context)
+ all_timesteps = sample_offline_timesteps(
+ self.training_args,
+ batch_size=len(preference.chosen_media),
+ device=self.accelerator.device,
+ )
+
+ timestep_losses: List[torch.Tensor] = []
+ timestep_metrics: Dict[str, List[torch.Tensor]] = defaultdict(list)
+ with self.accumulate_gradients():
+ for primary_timesteps in all_timesteps:
+ chosen_times, chosen_noised = build_noised_output_state(
+ self.adapter,
+ chosen.clean_state,
+ primary_timesteps,
+ batch=chosen_batch,
+ )
+ rejected_times, rejected_noised = build_noised_output_state(
+ self.adapter,
+ rejected.clean_state,
+ primary_timesteps,
+ batch=rejected_batch,
+ noise=chosen_noised.noise,
+ )
+ validate_preference_component_times(chosen_times, rejected_times)
+
+ with self.autocast():
+ policy_chosen = forward_velocity_state(
+ self,
+ chosen_batch,
+ chosen_noised.state,
+ chosen_times,
+ source="offline DPO policy chosen",
+ )
+ policy_rejected = forward_velocity_state(
+ self,
+ rejected_batch,
+ rejected_noised.state,
+ rejected_times,
+ source="offline DPO policy rejected",
+ )
+
+ # A full-parameter snapshot is installed once for both arms.
+ # LoRA adapters use the same scope to disable trainable adapters.
+ with torch.no_grad(), self.adapter.use_ref_parameters(), self.autocast():
+ reference_chosen = forward_velocity_state(
+ self,
+ chosen_batch,
+ chosen_noised.state,
+ chosen_times,
+ source="offline DPO reference chosen",
+ )
+ reference_rejected = forward_velocity_state(
+ self,
+ rejected_batch,
+ rejected_noised.state,
+ rejected_times,
+ source="offline DPO reference rejected",
+ )
+
+ policy_chosen_loss = flow_matching_per_sample_loss(
+ self.adapter,
+ policy_chosen,
+ chosen_noised,
+ )
+ policy_rejected_loss = flow_matching_per_sample_loss(
+ self.adapter,
+ policy_rejected,
+ rejected_noised,
+ )
+ reference_chosen_loss = flow_matching_per_sample_loss(
+ self.adapter,
+ reference_chosen,
+ chosen_noised,
+ )
+ reference_rejected_loss = flow_matching_per_sample_loss(
+ self.adapter,
+ reference_rejected,
+ rejected_noised,
+ )
+ loss, metrics = dpo_objective(
+ policy_chosen_loss=policy_chosen_loss,
+ policy_rejected_loss=policy_rejected_loss,
+ reference_chosen_loss=reference_chosen_loss,
+ reference_rejected_loss=reference_rejected_loss,
+ beta=self.training_args.beta,
+ )
+ timestep_losses.append(loss)
+ timestep_metrics["theta_w_err"].append(policy_chosen_loss.mean())
+ timestep_metrics["theta_l_err"].append(policy_rejected_loss.mean())
+ timestep_metrics["ref_w_err"].append(reference_chosen_loss.mean())
+ timestep_metrics["ref_l_err"].append(reference_rejected_loss.mean())
+ timestep_metrics["implicit_accuracy"].append(metrics["implicit_accuracy"])
+ timestep_metrics["implicit_reward_chosen"].append(
+ metrics["implicit_reward_chosen"].mean()
+ )
+ timestep_metrics["implicit_reward_rejected"].append(
+ metrics["implicit_reward_rejected"].mean()
+ )
+
+ loss = torch.stack(timestep_losses).mean()
+ self.accelerator.backward(loss)
+
+ loss_info = self._offline_loss_info()
+ loss_info["loss"].append(loss.detach())
+ for name, values in timestep_metrics.items():
+ loss_info[name].append(torch.stack(values).mean().detach())
+ if self.accelerator.sync_gradients:
+ self._offline_dpo_loss_info = self._apply_optimizer_step(loss_info)
+
+ def _offline_loss_info(self) -> MetricAccumulator:
+ """Return metrics accumulated across the current gradient window."""
+ loss_info = getattr(self, "_offline_dpo_loss_info", None)
+ if loss_info is None:
+ loss_info = defaultdict(list)
+ self._offline_dpo_loss_info = loss_info
+ return loss_info
+
+
+def _require_preference_batch(batch: Any) -> PreferenceOutputBatch:
+ """Validate the algorithm-owned portion of one collated offline batch."""
+ if type(batch) is not OfflineBatch:
+ raise TypeError(
+ "OfflineDPOTrainer requires an exact OfflineBatch, "
+ f"received {type(batch).__name__}: {batch!r}"
+ )
+ if batch.supervision_type != "preference":
+ raise ValueError(
+ "OfflineDPOTrainer requires supervision_type='preference', "
+ f"received {batch.supervision_type!r}"
+ )
+ if type(batch.output) is not PreferenceOutputBatch:
+ raise TypeError(
+ "OfflineDPOTrainer requires PreferenceOutputBatch output, "
+ f"received {type(batch.output).__name__}: {batch.output!r}"
+ )
+ output = batch.output
+ if type(output.chosen_media) is not tuple or type(output.rejected_media) is not tuple:
+ raise TypeError("offline preference media arms must be tuples")
+ if not output.chosen_media:
+ raise ValueError("offline preference batch must contain at least one pair")
+ if len(output.chosen_media) != len(output.rejected_media):
+ raise ValueError(
+ "offline preference arms must contain the same batch size, "
+ f"received chosen={len(output.chosen_media)} and "
+ f"rejected={len(output.rejected_media)}"
+ )
+ return output
+
+
+__all__ = ["OfflineDPOTrainer"]
diff --git a/src/flow_factory/trainers/offline/sft.py b/src/flow_factory/trainers/offline/sft.py
new file mode 100644
index 000000000..77f7c734d
--- /dev/null
+++ b/src/flow_factory/trainers/offline/sft.py
@@ -0,0 +1,145 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Offline supervised flow-matching trainer."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import Any, ClassVar, Dict, List, Literal, Tuple
+
+import torch
+from torch.utils.data import DataLoader
+
+from ...contracts import OFFLINE_EXECUTION_CONTRACT
+from ...data_utils.offline_dataset import DemonstrationOutputBatch, OfflineBatch
+from ...data_utils.offline_train_data import build_offline_train_dataloader
+from ..abc import BaseTrainer
+from ..common.flow_matching import (
+ build_noised_output_state,
+ flow_matching_per_sample_loss,
+ sample_offline_timesteps,
+)
+from ..common.offline_batch import bind_output_forward_context, move_condition_to_device
+from ..forward_process import forward_velocity_state
+
+
+class SFTTrainer(BaseTrainer):
+ """Train a flow-matching policy from a finite demonstration dataset."""
+
+ paradigm: ClassVar[Literal["decoupled"]] = "decoupled"
+ execution_contract = OFFLINE_EXECUTION_CONTRACT
+
+ def _build_train_dataloader(self) -> Tuple[DataLoader, Dict[str, DataLoader]]:
+ """Build the finite loader owned by its official distributed sampler."""
+ dataloader = build_offline_train_dataloader(
+ config=self.config,
+ accelerator=self.accelerator,
+ preprocess_func=self.adapter.preprocess_func,
+ supervision_type="demonstration",
+ pipeline_io_contract=self.adapter.pipeline_io_contract,
+ )
+ return dataloader, {}
+
+ def optimize_batch(self, batch: Any) -> None:
+ """Apply one gradient-accumulation microstep to a demonstration batch.
+
+ Target media is VAE-encoded on demand. Independently sampled time terms
+ are averaged inside this microstep, so they do not alter dataloader epoch,
+ gradient-accumulation, or optimizer-step cadence.
+
+ Args:
+ batch: One exact :class:`OfflineBatch` with demonstration output.
+ """
+ output = self._require_demonstration_batch(batch)
+ condition = move_condition_to_device(batch.condition, self.accelerator.device)
+
+ # Evaluation leaves trainable components in eval mode. Every finite-data
+ # microstep explicitly restores training mode before policy execution.
+ self.adapter.train()
+
+ encoded = self.adapter.encode_output_state(output.target_media, condition)
+ model_batch = bind_output_forward_context(condition, encoded.forward_context)
+ all_timesteps = sample_offline_timesteps(
+ self.training_args,
+ batch_size=len(output.target_media),
+ device=self.accelerator.device,
+ )
+
+ with self.accumulate_gradients():
+ time_losses = []
+ for primary_timesteps in all_timesteps:
+ times, noised = build_noised_output_state(
+ self.adapter,
+ encoded.clean_state,
+ primary_timesteps,
+ batch=model_batch,
+ )
+ with self.autocast():
+ predicted_velocity = forward_velocity_state(
+ self,
+ model_batch,
+ noised.state,
+ times,
+ source="SFT policy",
+ )
+ time_losses.append(
+ flow_matching_per_sample_loss(
+ self.adapter,
+ predicted_velocity,
+ noised,
+ )
+ )
+
+ per_sample_loss = torch.stack(time_losses, dim=0).mean(dim=0)
+ loss = per_sample_loss.mean()
+ self.accelerator.backward(loss)
+
+ # Only a completed backward enters the persistent accumulation window.
+ loss_info = self._get_loss_info()
+ loss_info["loss"].append(loss.detach())
+ loss_info["flow_matching_loss"].append(per_sample_loss.mean().detach())
+ if self.accelerator.sync_gradients:
+ self._loss_info = self._apply_optimizer_step(loss_info)
+
+ def _get_loss_info(self) -> Dict[str, List[torch.Tensor]]:
+ """Return the metric window, including for lightweight test instances."""
+ loss_info = getattr(self, "_loss_info", None)
+ if loss_info is None:
+ loss_info = defaultdict(list)
+ self._loss_info = loss_info
+ return loss_info
+
+ @staticmethod
+ def _require_demonstration_batch(batch: Any) -> DemonstrationOutputBatch:
+ """Return a demonstration output after strict algorithm-boundary checks."""
+ if type(batch) is not OfflineBatch:
+ raise TypeError(
+ "SFTTrainer requires an exact OfflineBatch, "
+ f"received {type(batch).__name__}: {batch!r}"
+ )
+ if batch.supervision_type != "demonstration":
+ raise ValueError(
+ "SFTTrainer requires supervision_type='demonstration', "
+ f"received {batch.supervision_type!r}"
+ )
+ if type(batch.output) is not DemonstrationOutputBatch:
+ raise TypeError(
+ "SFTTrainer requires DemonstrationOutputBatch output, "
+ f"received {type(batch.output).__name__}: {batch.output!r}"
+ )
+ return batch.output
+
+
+__all__ = ["SFTTrainer"]
diff --git a/src/flow_factory/trainers/registry.py b/src/flow_factory/trainers/registry.py
index c17a40409..884109a3b 100644
--- a/src/flow_factory/trainers/registry.py
+++ b/src/flow_factory/trainers/registry.py
@@ -41,6 +41,8 @@
"dmd2": "flow_factory.trainers.distillation.dmd2.DMD2Trainer",
"tdm": "flow_factory.trainers.distillation.tdm.TDMTrainer",
"tdm-r1": "flow_factory.trainers.distillation.tdm_r1.TDMR1Trainer",
+ "sft": "flow_factory.trainers.offline.sft.SFTTrainer",
+ "offline-dpo": "flow_factory.trainers.offline.offline_dpo.OfflineDPOTrainer",
}
diff --git a/tests/trainers/test_offline_flow_matching.py b/tests/trainers/test_offline_flow_matching.py
index c0d32d725..8f24a0f36 100644
--- a/tests/trainers/test_offline_flow_matching.py
+++ b/tests/trainers/test_offline_flow_matching.py
@@ -29,6 +29,7 @@
build_noised_output_state,
flow_matching_per_sample_loss,
sample_offline_timesteps,
+ validate_preference_component_times,
validate_preference_output_states,
)
@@ -201,6 +202,27 @@ def apply_forward_process_noise(
assert rejected.noise is chosen.noise
+def test_preference_component_times_reject_context_dependent_schedule_drift() -> None:
+ chosen = ComponentTimes(
+ timestep={"latent": torch.tensor([500.0, 250.0])},
+ next_timestep={"latent": torch.zeros(2)},
+ sigma={"latent": torch.tensor([0.5, 0.25])},
+ next_sigma={"latent": torch.zeros(2)},
+ )
+ rejected = ComponentTimes(
+ timestep={"latent": chosen.timestep["latent"].clone()},
+ next_timestep={"latent": chosen.next_timestep["latent"].clone()},
+ sigma={"latent": torch.tensor([0.5, 0.2])},
+ next_sigma={"latent": chosen.next_sigma["latent"].clone()},
+ )
+
+ with pytest.raises(ValueError, match="component times values mismatch"):
+ validate_preference_component_times(chosen, rejected)
+
+ rejected.sigma = {"latent": chosen.sigma["latent"].clone()}
+ validate_preference_component_times(chosen, rejected)
+
+
def test_flow_matching_loss_computes_fp32_errors_before_adapter_reduction() -> None:
predicted = LatentState(
{
diff --git a/tests/trainers/test_offline_trainers.py b/tests/trainers/test_offline_trainers.py
new file mode 100644
index 000000000..3798fe603
--- /dev/null
+++ b/tests/trainers/test_offline_trainers.py
@@ -0,0 +1,417 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from collections import defaultdict
+from contextlib import contextmanager, nullcontext
+from types import SimpleNamespace
+from typing import Any, Iterator, Mapping
+
+import pytest
+import torch
+
+from flow_factory.contracts import OFFLINE_EXECUTION_CONTRACT, MediaType
+from flow_factory.data_utils.offline_dataset import (
+ DecodedMedia,
+ DemonstrationOutputBatch,
+ OfflineBatch,
+ PreferenceOutputBatch,
+)
+from flow_factory.data_utils.schema import NormalizedModelInput
+from flow_factory.models.output_state import (
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+from flow_factory.samples import ComponentTimes, LatentState, MultiModalStepOutput, NoisedState
+from flow_factory.trainers.abc import BaseTrainer
+from flow_factory.trainers.execution import TrainingProgress
+from flow_factory.trainers.offline import offline_dpo as offline_dpo_module
+from flow_factory.trainers.offline import sft as sft_module
+from flow_factory.trainers.offline.offline_dpo import OfflineDPOTrainer
+from flow_factory.trainers.offline.sft import SFTTrainer
+from flow_factory.trainers.registry import get_trainer_class
+
+
+class _TrainingArgs(dict):
+ """Small mapping/attribute hybrid used by shared forward helpers."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.weighting_scheme = "uniform"
+ self.num_train_timesteps = 2
+ self.timestep_range = (0.0, 0.99)
+ self.time_shift = 1.0
+ self.logit_mean = 0.0
+ self.logit_std = 1.0
+ self.beta = 2.0
+
+
+class _Accelerator:
+ """Expose only the accumulation surface required by offline trainers."""
+
+ def __init__(self, sync_schedule: list[bool]) -> None:
+ self.device = torch.device("cpu")
+ self._sync_schedule = iter(sync_schedule)
+ self.sync_gradients = False
+ self.accumulate_roots: list[Any] = []
+ self.backward_losses: list[torch.Tensor] = []
+ self.prepare_calls = 0
+
+ @contextmanager
+ def accumulate(self, root: Any) -> Iterator[None]:
+ self.accumulate_roots.append(root)
+ self.sync_gradients = next(self._sync_schedule)
+ yield
+
+ def backward(self, loss: torch.Tensor) -> None:
+ self.backward_losses.append(loss.detach().clone())
+ loss.backward()
+
+ def prepare(self, *args: Any, **kwargs: Any) -> Any:
+ del args, kwargs
+ self.prepare_calls += 1
+ raise AssertionError("offline loader must remain owned by DistributedSampler")
+
+
+class _Adapter:
+ """Fake one-component codec and flow model with a reference scope."""
+
+ trajectory_component_order = ("latent",)
+
+ def __init__(self) -> None:
+ self.policy_weight = torch.nn.Parameter(torch.tensor(0.7))
+ self.preprocess_func = object()
+ self.pipeline_io_contract = object()
+ self.train_calls = 0
+ self.encode_calls: list[str] = []
+ self.forward_events: list[tuple[float, bool, bool]] = []
+ self.drawn_noise: list[LatentState] = []
+ self.reused_noise: list[LatentState] = []
+ self.ref_scope_enters = 0
+ self._ref_active = False
+
+ def train(self, mode: bool = True) -> None:
+ assert mode is True
+ self.train_calls += 1
+
+ def encode_output_state(
+ self,
+ media_batch: tuple[tuple[DecodedMedia, ...], ...],
+ condition: Mapping[str, Any],
+ generator: torch.Generator | None = None,
+ ) -> EncodedOutputState:
+ del condition, generator
+ arm = str(media_batch[0][0].payload)
+ self.encode_calls.append(arm)
+ arm_value = 1.0 if arm == "rejected" else 0.0
+ batch_size = len(media_batch)
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=8,
+ width=8,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=LatentState({"latent": torch.full((batch_size, 2), arm_value)}),
+ forward_context={
+ "arm_token": torch.full((batch_size, 1), arm_value),
+ },
+ decode_context={},
+ geometry_signatures=tuple(signature for _ in range(batch_size)),
+ )
+
+ def build_training_component_times(
+ self,
+ primary_timesteps: torch.Tensor,
+ *,
+ batch: Mapping[str, Any],
+ ) -> ComponentTimes:
+ assert batch["arm_token"].shape[0] == primary_timesteps.shape[0]
+ sigma = primary_timesteps.float() / 1000.0
+ return ComponentTimes(
+ timestep={"latent": primary_timesteps},
+ next_timestep={"latent": torch.zeros_like(primary_timesteps)},
+ sigma={"latent": sigma},
+ next_sigma={"latent": torch.zeros_like(sigma)},
+ )
+
+ def add_forward_process_noise(
+ self,
+ clean_state: LatentState,
+ times: ComponentTimes,
+ *,
+ generator: torch.Generator | None = None,
+ ) -> NoisedState:
+ del generator
+ noise = LatentState(
+ {
+ "latent": torch.full_like(
+ clean_state.components["latent"],
+ float(len(self.drawn_noise) + 1),
+ )
+ }
+ )
+ self.drawn_noise.append(noise)
+ return self.apply_forward_process_noise(clean_state, times, noise)
+
+ def apply_forward_process_noise(
+ self,
+ clean_state: LatentState,
+ times: ComponentTimes,
+ noise: LatentState,
+ ) -> NoisedState:
+ if any(noise is item for item in self.drawn_noise):
+ self.reused_noise.append(noise)
+ clean = clean_state.components["latent"]
+ noise_tensor = noise.components["latent"]
+ sigma = times.sigma["latent"].reshape(clean.shape[0], 1)
+ return NoisedState(
+ state=LatentState({"latent": clean * (1.0 - sigma) + noise_tensor * sigma}),
+ target_velocity=LatentState({"latent": noise_tensor - clean}),
+ noise=noise,
+ )
+
+ def forward_state(
+ self,
+ *,
+ batch: Mapping[str, Any],
+ state: LatentState,
+ times: ComponentTimes,
+ **kwargs: Any,
+ ) -> MultiModalStepOutput:
+ del kwargs
+ arm = batch["arm_token"]
+ coordinate = times.timestep["latent"].float().reshape(arm.shape[0], 1) / 1000.0
+ self.forward_events.append(
+ (float(arm[0].item()), self._ref_active, torch.is_grad_enabled())
+ )
+ if self._ref_active:
+ velocity = 0.2 * coordinate - 0.3 * arm
+ else:
+ velocity = self.policy_weight * (1.0 + coordinate) + 0.4 * arm
+ return MultiModalStepOutput(
+ velocity=LatentState({"latent": velocity.expand_as(state.components["latent"])})
+ )
+
+ @contextmanager
+ def use_ref_parameters(self) -> Iterator[None]:
+ assert not self._ref_active
+ assert not torch.is_grad_enabled()
+ self.ref_scope_enters += 1
+ self._ref_active = True
+ try:
+ yield
+ finally:
+ self._ref_active = False
+
+ @staticmethod
+ def reduce_latent_values(
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: LatentState,
+ ) -> torch.Tensor:
+ del state
+ return values["latent"].flatten(1).mean(dim=1)
+
+
+def _media(arm: str, batch_size: int = 2) -> tuple[tuple[DecodedMedia, ...], ...]:
+ return tuple(
+ (
+ DecodedMedia(
+ type="image",
+ path=f"{arm}-{index}.png",
+ payload=arm,
+ ),
+ )
+ for index in range(batch_size)
+ )
+
+
+def _batch(supervision_type: str, batch_size: int = 2) -> OfflineBatch:
+ if supervision_type == "demonstration":
+ output: DemonstrationOutputBatch | PreferenceOutputBatch = DemonstrationOutputBatch(
+ target_media=_media("target", batch_size)
+ )
+ else:
+ output = PreferenceOutputBatch(
+ chosen_media=_media("chosen", batch_size),
+ rejected_media=_media("rejected", batch_size),
+ )
+ return OfflineBatch(
+ condition={"prompt_embeds": torch.ones(batch_size, 2)},
+ condition_ids=tuple(f"condition-{index}" for index in range(batch_size)),
+ record_ids=tuple(f"record-{index}" for index in range(batch_size)),
+ sources=tuple("source" for _ in range(batch_size)),
+ source_ids=torch.zeros(batch_size, dtype=torch.long),
+ model_inputs=tuple(
+ NormalizedModelInput(prompt="prompt", negative_prompt=None, media=())
+ for _ in range(batch_size)
+ ),
+ supervision_type=supervision_type,
+ output=output,
+ metadata_json=tuple("{}" for _ in range(batch_size)),
+ )
+
+
+def _trainer(
+ trainer_type: type[SFTTrainer] | type[OfflineDPOTrainer],
+ sync_schedule: list[bool],
+) -> tuple[SFTTrainer | OfflineDPOTrainer, _Adapter, list[dict[str, list[torch.Tensor]]]]:
+ trainer = object.__new__(trainer_type)
+ trainer.accelerator = _Accelerator(sync_schedule)
+ trainer.adapter = _Adapter()
+ trainer.training_args = _TrainingArgs()
+ trainer.model_bundle = object()
+ trainer.autocast = nullcontext
+ trainer.progress = TrainingProgress()
+ optimizer_windows: list[dict[str, list[torch.Tensor]]] = []
+
+ def apply_optimizer_step(
+ loss_info: dict[str, list[torch.Tensor]],
+ ) -> dict[str, list[torch.Tensor]]:
+ optimizer_windows.append({name: list(values) for name, values in loss_info.items()})
+ trainer.step += 1
+ return defaultdict(list)
+
+ trainer._apply_optimizer_step = apply_optimizer_step
+ return trainer, trainer.adapter, optimizer_windows
+
+
+def test_offline_trainers_are_dataset_driven_and_registered() -> None:
+ assert SFTTrainer.__bases__ == (BaseTrainer,)
+ assert OfflineDPOTrainer.__bases__ == (BaseTrainer,)
+ assert SFTTrainer.execution_contract is OFFLINE_EXECUTION_CONTRACT
+ assert OfflineDPOTrainer.execution_contract is OFFLINE_EXECUTION_CONTRACT
+ assert SFTTrainer.paradigm == OfflineDPOTrainer.paradigm == "decoupled"
+ assert "sample" not in SFTTrainer.__dict__
+ assert "sample" not in OfflineDPOTrainer.__dict__
+ assert get_trainer_class("sft") is SFTTrainer
+ assert get_trainer_class("offline-dpo") is OfflineDPOTrainer
+
+
+@pytest.mark.parametrize(
+ ("trainer_type", "module", "supervision_type"),
+ [
+ (SFTTrainer, sft_module, "demonstration"),
+ (OfflineDPOTrainer, offline_dpo_module, "preference"),
+ ],
+)
+def test_offline_trainers_build_unprepared_distributed_loaders(
+ monkeypatch: pytest.MonkeyPatch,
+ trainer_type: type[SFTTrainer] | type[OfflineDPOTrainer],
+ module: Any,
+ supervision_type: str,
+) -> None:
+ trainer = object.__new__(trainer_type)
+ trainer.config = object()
+ trainer.accelerator = _Accelerator([])
+ trainer.adapter = SimpleNamespace(
+ preprocess_func=object(),
+ pipeline_io_contract=object(),
+ )
+ sentinel = object()
+ received: dict[str, Any] = {}
+
+ def fake_builder(**kwargs: Any) -> Any:
+ received.update(kwargs)
+ return sentinel
+
+ monkeypatch.setattr(module, "build_offline_train_dataloader", fake_builder)
+
+ loader, source_loaders = trainer._build_train_dataloader()
+
+ assert loader is sentinel
+ assert source_loaders == {}
+ assert received == {
+ "config": trainer.config,
+ "accelerator": trainer.accelerator,
+ "preprocess_func": trainer.adapter.preprocess_func,
+ "supervision_type": supervision_type,
+ "pipeline_io_contract": trainer.adapter.pipeline_io_contract,
+ }
+ assert trainer.accelerator.prepare_calls == 0
+
+
+def test_sft_reencodes_targets_and_preserves_optimizer_cadence(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ trainer, adapter, optimizer_windows = _trainer(SFTTrainer, [False, True])
+ monkeypatch.setattr(
+ sft_module,
+ "sample_offline_timesteps",
+ lambda *args, **kwargs: torch.tensor([[250.0, 250.0], [750.0, 750.0]]),
+ )
+
+ trainer.optimize_batch(_batch("demonstration"))
+ assert trainer.step == 0
+ trainer.optimize_batch(_batch("demonstration"))
+
+ assert trainer.step == 1
+ assert adapter.train_calls == 2
+ assert adapter.encode_calls == ["target", "target"]
+ assert len(trainer.accelerator.backward_losses) == 2
+ assert len(trainer.accelerator.accumulate_roots) == 2
+ assert len(optimizer_windows) == 1
+ assert len(optimizer_windows[0]["loss"]) == 2
+ assert len(optimizer_windows[0]["flow_matching_loss"]) == 2
+
+
+def test_offline_dpo_shares_schedule_noise_and_reference_scope(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ trainer, adapter, optimizer_windows = _trainer(OfflineDPOTrainer, [True])
+ monkeypatch.setattr(
+ offline_dpo_module,
+ "sample_offline_timesteps",
+ lambda *args, **kwargs: torch.tensor([[250.0, 250.0], [750.0, 750.0]]),
+ )
+
+ trainer.optimize_batch(_batch("preference"))
+
+ assert trainer.step == 1
+ assert adapter.encode_calls == ["chosen", "rejected"]
+ assert len(adapter.drawn_noise) == 2
+ assert len(adapter.reused_noise) == 4
+ assert adapter.reused_noise[0] is adapter.drawn_noise[0]
+ assert adapter.reused_noise[1] is adapter.drawn_noise[0]
+ assert adapter.reused_noise[2] is adapter.drawn_noise[1]
+ assert adapter.reused_noise[3] is adapter.drawn_noise[1]
+ assert adapter.ref_scope_enters == 2
+ assert len(optimizer_windows) == 1
+ assert len(optimizer_windows[0]) == 8
+ reference_events = [event for event in adapter.forward_events if event[1]]
+ policy_events = [event for event in adapter.forward_events if not event[1]]
+ assert len(reference_events) == len(policy_events) == 4
+ assert all(not grad_enabled for _, _, grad_enabled in reference_events)
+ assert all(grad_enabled for _, _, grad_enabled in policy_events)
+
+
+def test_offline_trainers_reject_the_other_supervision_branch() -> None:
+ sft, sft_adapter, _ = _trainer(SFTTrainer, [])
+ dpo, dpo_adapter, _ = _trainer(OfflineDPOTrainer, [])
+
+ with pytest.raises(ValueError, match="supervision_type='demonstration'"):
+ sft.optimize_batch(_batch("preference"))
+ with pytest.raises(ValueError, match="supervision_type='preference'"):
+ dpo.optimize_batch(_batch("demonstration"))
+ with pytest.raises(TypeError, match="exact OfflineBatch"):
+ dpo.optimize_batch(object())
+
+ assert sft_adapter.train_calls == 0
+ assert dpo_adapter.train_calls == 0
From 615277eebbe03f1591b061ea4c57016e2ea1e430 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:36:10 +0800
Subject: [PATCH 12/76] feat(models): add classic image output codecs
---
src/flow_factory/models/flux/_output.py | 50 +++
src/flow_factory/models/flux/flux1.py | 37 +-
src/flow_factory/models/flux/flux1_kontext.py | 93 ++++-
.../models/stable_diffusion/sd3_5.py | 40 +-
src/flow_factory/models/z_image/z_image.py | 45 ++-
.../test_classic_image_output_codecs.py | 371 ++++++++++++++++++
6 files changed, 621 insertions(+), 15 deletions(-)
create mode 100644 tests/models/test_classic_image_output_codecs.py
diff --git a/src/flow_factory/models/flux/_output.py b/src/flow_factory/models/flux/_output.py
index 67cb7d954..3ecf69e9c 100644
--- a/src/flow_factory/models/flux/_output.py
+++ b/src/flow_factory/models/flux/_output.py
@@ -56,6 +56,55 @@ def encode_flux1_vae_image(
)
+def prepare_flux1_output_latents(
+ adapter: Any,
+ pixel_values: torch.Tensor,
+ generator: Optional[torch.Generator],
+) -> Tuple[torch.Tensor, torch.Tensor]:
+ """Sample and pack FLUX.1 target latents with their target-first IDs.
+
+ Args:
+ adapter: FLUX.1 adapter exposing the canonical VAE and pipeline helpers.
+ pixel_values: Preprocessed BCHW target images.
+ generator: Generator forwarded unchanged to target posterior sampling.
+
+ Returns:
+ Packed clean target latents and their unbatched position identifiers.
+ """
+ latents = encode_flux1_vae_image(
+ adapter,
+ pixel_values,
+ sample_mode="sample",
+ generator=generator,
+ )
+ if latents.ndim != 4:
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.1 target VAE expected BCHW latents, "
+ f"received shape {tuple(latents.shape)}"
+ )
+ batch_size, channels, latent_height, latent_width = latents.shape
+ if latent_height % 2 or latent_width % 2:
+ raise ValueError(
+ f"{type(adapter).__name__} FLUX.1 target packing expected even latent height/width, "
+ f"received {(latent_height, latent_width)}"
+ )
+ packed = adapter.pipeline._pack_latents(
+ latents,
+ batch_size,
+ channels,
+ latent_height,
+ latent_width,
+ )
+ target_ids = adapter.pipeline._prepare_latent_image_ids(
+ batch_size,
+ latent_height // 2,
+ latent_width // 2,
+ adapter.device,
+ packed.dtype,
+ )
+ return packed, target_ids
+
+
def encode_flux2_output_images(
adapter: Any,
pixel_values: torch.Tensor,
@@ -214,5 +263,6 @@ def prepare_flux2_condition_latents(
"encode_flux1_vae_image",
"encode_flux2_output_images",
"encode_flux2_vae_image",
+ "prepare_flux1_output_latents",
"prepare_flux2_condition_latents",
]
diff --git a/src/flow_factory/models/flux/flux1.py b/src/flow_factory/models/flux/flux1.py
index 958765f74..80ef1dae1 100644
--- a/src/flow_factory/models/flux/flux1.py
+++ b/src/flow_factory/models/flux/flux1.py
@@ -19,7 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
@@ -27,6 +27,7 @@
from diffusers.pipelines.flux.pipeline_flux import FluxPipeline
from PIL import Image
+from ...contracts import NegativePromptPolicy
from ...hparams import *
from ...samples import T2ISample
from ...scheduler import (
@@ -45,6 +46,12 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import (
+ ConfiguredImageOutputAdapterMixin,
+ EncodedImageTensor,
+)
+from ..pipeline_contracts import image_output_contract
+from ._output import prepare_flux1_output_latents
logger = setup_logger(__name__)
@@ -60,9 +67,13 @@ class Flux1Sample(T2ISample):
img_ids: Optional[torch.Tensor] = None
-class Flux1Adapter(BaseAdapter):
+class Flux1Adapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
"""Concrete implementation for Flow Matching models (FLUX.1)."""
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ )
+
def __init__(self, config: Arguments, accelerator: Accelerator):
super().__init__(config, accelerator)
self.pipeline: FluxPipeline
@@ -70,8 +81,7 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
def load_pipeline(self) -> FluxPipeline:
return self._load_diffusers_pipeline(
- FluxPipeline,
- self.model_args.model_name_or_path, low_cpu_mem_usage=False
+ FluxPipeline, self.model_args.model_name_or_path, low_cpu_mem_usage=False
)
@property
@@ -144,6 +154,25 @@ def encode_video(self, videos: Union[torch.Tensor, List[torch.Tensor]]) -> None:
"""Not needed for FLUX text-to-image models."""
pass
+ def _output_geometry_multiple(self) -> int:
+ """Require the VAE grid and 2x2 latent packing used by Diffusers."""
+ return self.pipeline.vae_scale_factor * 2
+
+ def _encode_output_images(
+ self,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ ) -> EncodedImageTensor:
+ """Sample FLUX.1 targets and attach their packed position identifiers."""
+ del condition
+ packed, img_ids = prepare_flux1_output_latents(self, pixel_values, generator)
+ return EncodedImageTensor(
+ latents=packed,
+ forward_context={"img_ids": img_ids},
+ decode_context={},
+ )
+
def decode_latents(
self,
latents: torch.Tensor,
diff --git a/src/flow_factory/models/flux/flux1_kontext.py b/src/flow_factory/models/flux/flux1_kontext.py
index c2086f863..d9f9007e4 100644
--- a/src/flow_factory/models/flux/flux1_kontext.py
+++ b/src/flow_factory/models/flux/flux1_kontext.py
@@ -19,7 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
@@ -28,6 +28,7 @@
from diffusers.utils.torch_utils import randn_tensor
from PIL import Image
+from ...contracts import NegativePromptPolicy
from ...hparams import *
from ...samples import I2ISample
from ...scheduler import (
@@ -55,6 +56,12 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import (
+ ConfiguredImageOutputAdapterMixin,
+ EncodedImageTensor,
+)
+from ..pipeline_contracts import image_output_contract
+from ._output import encode_flux1_vae_image, prepare_flux1_output_latents
logger = setup_logger(__name__)
@@ -123,9 +130,15 @@ def adjust_image_dimension(
return height, width
-class Flux1KontextAdapter(BaseAdapter):
+class Flux1KontextAdapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
"""Concrete implementation for Flow Matching models (FLUX.1)."""
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_image_min_count=1,
+ input_image_max_count=1,
+ )
+
def __init__(self, config: Arguments, accelerator: Accelerator):
super().__init__(config, accelerator)
self.pipeline: FluxKontextPipeline
@@ -135,8 +148,7 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
def load_pipeline(self) -> FluxKontextPipeline:
return self._load_diffusers_pipeline(
- FluxKontextPipeline,
- self.model_args.model_name_or_path, low_cpu_mem_usage=False
+ FluxKontextPipeline, self.model_args.model_name_or_path, low_cpu_mem_usage=False
)
@property
@@ -282,7 +294,11 @@ def encode_image(
image_tensors = self.pipeline.image_processor.preprocess(images, image_height, image_width)
# 2. Prepare `image_latents` and `image_ids`
image_tensors = image_tensors.to(device=device, dtype=dtype)
- image_latents = self.pipeline._encode_vae_image(image=image_tensors, generator=generator)
+ image_latents = encode_flux1_vae_image(
+ self,
+ image_tensors,
+ sample_mode="argmax",
+ )
image_latent_height, image_latent_width = image_latents.shape[2:]
image_latents = self.pipeline._pack_latents(
image_latents, batch_size, num_channels_latents, image_latent_height, image_latent_width
@@ -310,6 +326,73 @@ def encode_video(self, videos: Any) -> None:
"""Flux.2 does not support video encoding."""
pass
+ def _output_geometry_multiple(self) -> int:
+ """Require the VAE grid and 2x2 latent packing used by Diffusers."""
+ return self.pipeline.vae_scale_factor * 2
+
+ def _encode_output_images(
+ self,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ ) -> EncodedImageTensor:
+ """Sample target latents and prepend their IDs to cached condition IDs."""
+ packed, target_ids = prepare_flux1_output_latents(self, pixel_values, generator)
+ condition_ids = self._shared_condition_image_ids(
+ condition,
+ batch_size=packed.shape[0],
+ dtype=packed.dtype,
+ )
+ return EncodedImageTensor(
+ latents=packed,
+ forward_context={"latent_ids": torch.cat([target_ids, condition_ids], dim=0)},
+ decode_context={},
+ )
+
+ def _shared_condition_image_ids(
+ self,
+ condition: Mapping[str, Any],
+ *,
+ batch_size: int,
+ dtype: torch.dtype,
+ ) -> torch.Tensor:
+ """Resolve cached uniform condition IDs to the shared Diffusers layout."""
+ image_ids = condition.get("image_ids")
+ if isinstance(image_ids, list):
+ if len(image_ids) != batch_size or not all(
+ isinstance(item, torch.Tensor) for item in image_ids
+ ):
+ raise TypeError(
+ "FLUX.1 Kontext output codec expected one tensor image_ids item per "
+ f"sample, received {type(image_ids).__name__} of length {len(image_ids)}"
+ )
+ image_ids = torch.stack(image_ids, dim=0)
+ if not isinstance(image_ids, torch.Tensor):
+ raise TypeError(
+ "FLUX.1 Kontext output codec requires cached condition['image_ids'] as "
+ f"torch.Tensor, received {type(image_ids).__name__}"
+ )
+ if image_ids.ndim == 2:
+ shared = image_ids
+ elif image_ids.ndim == 3 and image_ids.shape[0] == batch_size:
+ shared = image_ids[0]
+ if not torch.equal(image_ids, shared.unsqueeze(0).expand_as(image_ids)):
+ raise ValueError(
+ "FLUX.1 Kontext batched condition image_ids must be identical because "
+ "the adapter forward consumes one shared position-id sequence"
+ )
+ else:
+ raise ValueError(
+ "FLUX.1 Kontext condition image_ids expected shape (S, 3) or (B, S, 3), "
+ f"received {tuple(image_ids.shape)}"
+ )
+ if shared.shape[-1] != 3:
+ raise ValueError(
+ "FLUX.1 Kontext condition image_ids expected final dimension 3, "
+ f"received {tuple(shared.shape)}"
+ )
+ return shared.to(device=self.device, dtype=dtype)
+
def decode_latents(
self, latents: torch.Tensor, height, width, output_type="pil"
) -> List[Union[Image.Image, torch.Tensor, np.ndarray]]:
diff --git a/src/flow_factory/models/stable_diffusion/sd3_5.py b/src/flow_factory/models/stable_diffusion/sd3_5.py
index 0dd7842de..c9b0d00ca 100644
--- a/src/flow_factory/models/stable_diffusion/sd3_5.py
+++ b/src/flow_factory/models/stable_diffusion/sd3_5.py
@@ -18,7 +18,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import torch
from accelerate import Accelerator
@@ -27,6 +27,7 @@
)
from PIL import Image
+from ...contracts import NegativePromptPolicy
from ...hparams import *
from ...samples import BaseSample
from ...scheduler import (
@@ -45,6 +46,12 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import (
+ ConfiguredImageOutputAdapterMixin,
+ EncodedImageTensor,
+ encode_shift_scale_vae_image,
+)
+from ..pipeline_contracts import image_output_contract
logger = setup_logger(__name__)
@@ -60,9 +67,13 @@ class SD3_5Sample(BaseSample):
negative_pooled_prompt_embeds: Optional[torch.Tensor] = None
-class SD3_5Adapter(BaseAdapter):
+class SD3_5Adapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
"""Concrete implementation for Stable Diffusion 3 medium."""
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ )
+
def __init__(self, config: Arguments, accelerator: Accelerator):
super().__init__(config, accelerator)
self.pipeline: StableDiffusion3Pipeline
@@ -94,6 +105,31 @@ def default_target_modules(self) -> List[str]:
def tokenizer(self) -> Any:
return self.pipeline.tokenizer_3
+ def _output_geometry_multiple(self) -> int:
+ """Match the spatial divisibility enforced by Diffusers SD3 inputs."""
+ return self.pipeline.vae_scale_factor * self.pipeline.patch_size
+
+ def _encode_output_images(
+ self,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ ) -> EncodedImageTensor:
+ """Sample and normalize SD3.5 target latents on demand."""
+ del condition
+ latents = encode_shift_scale_vae_image(
+ self,
+ pixel_values,
+ sample_mode="sample",
+ generator=generator,
+ source="SD3.5 target VAE encode",
+ )
+ return EncodedImageTensor(
+ latents=latents,
+ forward_context={},
+ decode_context={},
+ )
+
# ============================ Encoding & Decoding ============================
def encode_prompt(
self,
diff --git a/src/flow_factory/models/z_image/z_image.py b/src/flow_factory/models/z_image/z_image.py
index 03d010321..56ef3b52e 100644
--- a/src/flow_factory/models/z_image/z_image.py
+++ b/src/flow_factory/models/z_image/z_image.py
@@ -19,13 +19,14 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import torch
from accelerate import Accelerator
from diffusers.pipelines.z_image.pipeline_z_image import ZImagePipeline
from PIL import Image
+from ...contracts import NegativePromptPolicy
from ...hparams import *
from ...samples import T2ISample
from ...scheduler import (
@@ -44,6 +45,12 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import (
+ ConfiguredImageOutputAdapterMixin,
+ EncodedImageTensor,
+ encode_shift_scale_vae_image,
+)
+from ..pipeline_contracts import image_output_contract
logger = setup_logger(__name__)
@@ -55,7 +62,13 @@ class ZImageSample(T2ISample):
# Obj var - no extra
-class ZImageAdapter(BaseAdapter):
+class ZImageAdapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
+ """Adapt Z-Image for online generation and offline image targets."""
+
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ )
+
# Z-Image trains and serves its diffusion transformer in FP32.
component_load_dtype_defaults = {"transformer": torch.float32}
@@ -66,8 +79,7 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
def load_pipeline(self) -> ZImagePipeline:
return self._load_diffusers_pipeline(
- ZImagePipeline,
- self.model_args.model_name_or_path, low_cpu_mem_usage=False
+ ZImagePipeline, self.model_args.model_name_or_path, low_cpu_mem_usage=False
)
@property
@@ -193,6 +205,31 @@ def encode_video(
"""Not needed for Z-Image models."""
pass
+ def _output_geometry_multiple(self) -> int:
+ """Require the exact spatial grid enforced by the official pipeline."""
+ return self.pipeline.vae_scale_factor * 2
+
+ def _encode_output_images(
+ self,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ ) -> EncodedImageTensor:
+ """Sample and normalize Z-Image target latents on demand."""
+ del condition
+ latents = encode_shift_scale_vae_image(
+ self,
+ pixel_values,
+ sample_mode="sample",
+ generator=generator,
+ source="Z-Image target VAE encode",
+ )
+ return EncodedImageTensor(
+ latents=latents,
+ forward_context={},
+ decode_context={},
+ )
+
def decode_latents(
self,
latents: torch.Tensor,
diff --git a/tests/models/test_classic_image_output_codecs.py b/tests/models/test_classic_image_output_codecs.py
new file mode 100644
index 000000000..d68d84b34
--- /dev/null
+++ b/tests/models/test_classic_image_output_codecs.py
@@ -0,0 +1,371 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Fake-only coverage for classic image-family offline output codecs."""
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Any, Optional
+
+import pytest
+import torch
+from PIL import Image
+
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ MediaType,
+ NegativePromptPolicy,
+)
+from flow_factory.models.flux.flux1 import Flux1Adapter
+from flow_factory.models.flux.flux1_kontext import Flux1KontextAdapter
+from flow_factory.models.stable_diffusion.sd3_5 import SD3_5Adapter
+from flow_factory.models.z_image.z_image import ZImageAdapter
+
+HEIGHT = 32
+WIDTH = 32
+
+
+@dataclass(frozen=True)
+class _DecodedMedia:
+ type: str
+ payload: Any
+ fps: Optional[float] = None
+ sample_rate: Optional[int] = None
+
+
+class _Processor:
+ def __init__(self) -> None:
+ self.preprocess_calls: list[tuple[int, int, int, bool]] = []
+
+ def get_default_height_width(self, image: Image.Image) -> tuple[int, int]:
+ del image
+ return HEIGHT, WIDTH
+
+ def resize(
+ self,
+ images: list[Image.Image],
+ height: int,
+ width: int,
+ ) -> list[Image.Image]:
+ del height, width
+ return images
+
+ def preprocess(
+ self,
+ images: list[Image.Image],
+ height: int,
+ width: int,
+ ) -> torch.Tensor:
+ self.preprocess_calls.append((len(images), height, width, torch.is_grad_enabled()))
+ return torch.arange(
+ len(images) * 3 * height * width,
+ dtype=torch.float32,
+ ).reshape(len(images), 3, height, width)
+
+ def postprocess(self, values: torch.Tensor, *, output_type: str) -> torch.Tensor:
+ assert output_type == "pt"
+ return values
+
+
+class _Posterior:
+ def __init__(self, value: torch.Tensor) -> None:
+ self.value = value
+ self.mode_calls = 0
+ self.sample_generators: list[Optional[torch.Generator]] = []
+
+ def mode(self) -> torch.Tensor:
+ self.mode_calls += 1
+ return self.value
+
+ def sample(self, generator: Optional[torch.Generator] = None) -> torch.Tensor:
+ self.sample_generators.append(generator)
+ return self.value + 7.0
+
+
+class _ConvVAE:
+ dtype = torch.float32
+ device = torch.device("cpu")
+
+ def __init__(self, *, shift: float = 1.5, scale: float = 2.0) -> None:
+ self.config = SimpleNamespace(shift_factor=shift, scaling_factor=scale)
+ self.encode_inputs: list[torch.Tensor] = []
+ self.posteriors: list[_Posterior] = []
+
+ def encode(self, values: torch.Tensor) -> Any:
+ self.encode_inputs.append(values)
+ posterior = _Posterior(values[:, :2, ::8, ::8] + len(self.encode_inputs))
+ self.posteriors.append(posterior)
+ return SimpleNamespace(latent_dist=posterior)
+
+
+class _Runtime:
+ def __init__(self, vae: _ConvVAE) -> None:
+ self.vae = vae
+ self.lookups: list[str] = []
+
+ def get_component(self, name: str) -> _ConvVAE:
+ self.lookups.append(name)
+ assert name == "vae"
+ return self.vae
+
+
+class _FluxPipeline:
+ vae_scale_factor = 8
+
+ def __init__(self, processor: _Processor, vae: _ConvVAE) -> None:
+ self.image_processor = processor
+ self.vae = vae
+ self.transformer = SimpleNamespace(config=SimpleNamespace(in_channels=8))
+
+ @staticmethod
+ def _pack_latents(
+ latents: torch.Tensor,
+ batch_size: int,
+ channels: int,
+ height: int,
+ width: int,
+ ) -> torch.Tensor:
+ assert tuple(latents.shape) == (batch_size, channels, height, width)
+ return (
+ latents.reshape(batch_size, channels, height // 2, 2, width // 2, 2)
+ .permute(0, 2, 4, 1, 3, 5)
+ .reshape(batch_size, height // 2 * (width // 2), channels * 4)
+ )
+
+ @staticmethod
+ def _prepare_latent_image_ids(
+ batch_size: int,
+ height: int,
+ width: int,
+ device: torch.device,
+ dtype: torch.dtype,
+ ) -> torch.Tensor:
+ del batch_size
+ ids = torch.zeros(height, width, 3, device=device, dtype=dtype)
+ ids[..., 1] = torch.arange(height, device=device, dtype=dtype)[:, None]
+ ids[..., 2] = torch.arange(width, device=device, dtype=dtype)[None, :]
+ return ids.reshape(height * width, 3)
+
+
+def _media_batch(batch_size: int = 2) -> tuple[tuple[_DecodedMedia, ...], ...]:
+ return tuple(
+ (
+ _DecodedMedia(
+ type="image",
+ payload=Image.new("RGB", (11 + index, 13 + index)),
+ ),
+ )
+ for index in range(batch_size)
+ )
+
+
+def _install_runtime(
+ adapter_cls: type,
+ *,
+ pipeline: Any,
+ vae: _ConvVAE,
+) -> tuple[Any, _Runtime]:
+ adapter = object.__new__(adapter_cls)
+ adapter.training_args = SimpleNamespace(
+ height=HEIGHT,
+ width=WIDTH,
+ latent_storage_dtype=None,
+ )
+ adapter.accelerator = SimpleNamespace(device=torch.device("cpu"))
+ adapter.pipeline = pipeline
+ runtime = _Runtime(vae)
+ adapter.component_runtime = runtime
+ adapter._output_state_encoding_modules = ("vae",)
+ adapter._output_state_codec = adapter.build_output_state_codec()
+ return adapter, runtime
+
+
+@pytest.mark.parametrize(
+ ("adapter_cls", "negative_prompt"),
+ [
+ (SD3_5Adapter, NegativePromptPolicy.OPTIONAL),
+ (Flux1Adapter, NegativePromptPolicy.UNSUPPORTED),
+ (Flux1KontextAdapter, NegativePromptPolicy.UNSUPPORTED),
+ (ZImageAdapter, NegativePromptPolicy.OPTIONAL),
+ ],
+)
+def test_classic_image_adapters_declare_static_offline_capability(
+ adapter_cls: type,
+ negative_prompt: NegativePromptPolicy,
+) -> None:
+ """The class preflight proves image output semantics without loading weights."""
+ adapter_cls.validate_offline_output_capability()
+ contract = adapter_cls.pipeline_io_contract
+
+ assert contract is not None
+ assert contract.negative_prompt is negative_prompt
+ assert tuple(item.type for item in contract.output_media.items) == (MediaType.IMAGE,)
+ assert contract.geometry_source is GeometrySource.CONFIGURED
+ assert contract.batch_capability is BatchCapability.UNIFORM
+
+
+def test_kontext_contract_requires_exactly_one_condition_image() -> None:
+ """Offline manifests cannot inherit the online first-image fallback."""
+ contract = Flux1KontextAdapter.pipeline_io_contract
+
+ assert contract is not None
+ assert len(contract.input_media.rules) == 1
+ rule = contract.input_media.rules[0]
+ assert rule.format.type is MediaType.IMAGE
+ assert rule.min_count == 1
+ assert rule.max_count == 1
+
+
+@pytest.mark.parametrize(
+ "adapter_cls",
+ [SD3_5Adapter, Flux1Adapter, Flux1KontextAdapter, ZImageAdapter],
+)
+def test_codec_construction_only_declares_logical_vae_requirement(adapter_cls: type) -> None:
+ """Building a codec performs no component lookup, movement, or materialization."""
+ processor = _Processor()
+ vae = _ConvVAE()
+ pipeline: Any = _FluxPipeline(processor, vae)
+ if adapter_cls is SD3_5Adapter:
+ pipeline.patch_size = 2
+ adapter, runtime = _install_runtime(adapter_cls, pipeline=pipeline, vae=vae)
+
+ assert runtime.lookups == []
+ assert adapter.output_state_codec.required_components == ("vae",)
+ assert adapter.output_state_encoding_modules == ("vae",)
+
+
+@pytest.mark.parametrize(
+ ("adapter_cls", "shift", "scale"),
+ [
+ (SD3_5Adapter, 1.5, 2.0),
+ (ZImageAdapter, 0.25, 1.75),
+ ],
+)
+def test_conv_image_targets_are_sampled_on_the_fly_with_the_caller_generator(
+ adapter_cls: type,
+ shift: float,
+ scale: float,
+) -> None:
+ """SD3.5 and Z-Image re-encode every target instead of reading a latent cache."""
+ processor = _Processor()
+ vae = _ConvVAE(shift=shift, scale=scale)
+ pipeline = _FluxPipeline(processor, vae)
+ pipeline.patch_size = 2
+ adapter, _ = _install_runtime(adapter_cls, pipeline=pipeline, vae=vae)
+ generator = torch.Generator().manual_seed(17)
+
+ first = adapter.encode_output_state(_media_batch(), {}, generator)
+ second = adapter.encode_output_state(_media_batch(), {}, generator)
+
+ assert len(vae.encode_inputs) == 2
+ assert processor.preprocess_calls == [
+ (2, HEIGHT, WIDTH, False),
+ (2, HEIGHT, WIDTH, False),
+ ]
+ assert all(posterior.mode_calls == 0 for posterior in vae.posteriors)
+ assert all(posterior.sample_generators == [generator] for posterior in vae.posteriors)
+ expected_first = (vae.posteriors[0].value + 7.0 - shift) * scale
+ expected_second = (vae.posteriors[1].value + 7.0 - shift) * scale
+ assert torch.equal(first.clean_state.components["latent"], expected_first)
+ assert torch.equal(second.clean_state.components["latent"], expected_second)
+ assert not torch.equal(expected_first, expected_second)
+ assert dict(first.forward_context) == {}
+ assert dict(first.decode_context) == {"height": HEIGHT, "width": WIDTH}
+
+
+def test_flux1_targets_are_sampled_then_packed_with_target_ids() -> None:
+ """The target codec uses FLUX packing and exposes the IDs consumed by forward."""
+ processor = _Processor()
+ vae = _ConvVAE()
+ pipeline = _FluxPipeline(processor, vae)
+ adapter, _ = _install_runtime(Flux1Adapter, pipeline=pipeline, vae=vae)
+ generator = torch.Generator().manual_seed(19)
+
+ encoded = adapter.encode_output_state(_media_batch(), {}, generator)
+
+ posterior = vae.posteriors[0]
+ expected = (posterior.value + 7.0 - 1.5) * 2.0
+ expected = pipeline._pack_latents(expected, 2, 2, 4, 4)
+ assert posterior.mode_calls == 0
+ assert posterior.sample_generators == [generator]
+ assert torch.equal(encoded.clean_state.components["latent"], expected)
+ assert tuple(encoded.forward_context) == ("img_ids",)
+ assert encoded.forward_context["img_ids"].shape == (4, 3)
+ assert dict(encoded.decode_context) == {"height": HEIGHT, "width": WIDTH}
+
+
+def test_kontext_targets_prepend_target_ids_to_shared_condition_ids() -> None:
+ """Target token IDs precede condition IDs exactly as Kontext forward concatenates states."""
+ processor = _Processor()
+ vae = _ConvVAE()
+ pipeline = _FluxPipeline(processor, vae)
+ adapter, _ = _install_runtime(Flux1KontextAdapter, pipeline=pipeline, vae=vae)
+ generator = torch.Generator().manual_seed(23)
+ condition_ids = torch.ones(2, 5, 3)
+
+ encoded = adapter.encode_output_state(
+ _media_batch(),
+ {"image_ids": condition_ids},
+ generator,
+ )
+
+ ids = encoded.forward_context["latent_ids"]
+ assert vae.posteriors[0].mode_calls == 0
+ assert vae.posteriors[0].sample_generators == [generator]
+ assert ids.shape == (9, 3)
+ assert torch.equal(ids[:4], pipeline._prepare_latent_image_ids(2, 2, 2, ids.device, ids.dtype))
+ assert torch.equal(ids[4:], condition_ids[0])
+
+
+def test_kontext_rejects_nonuniform_batched_condition_ids() -> None:
+ """One shared forward ID sequence cannot represent differing condition geometry."""
+ processor = _Processor()
+ vae = _ConvVAE()
+ pipeline = _FluxPipeline(processor, vae)
+ adapter, _ = _install_runtime(Flux1KontextAdapter, pipeline=pipeline, vae=vae)
+ condition_ids = torch.zeros(2, 5, 3)
+ condition_ids[1, :, 0] = 1
+
+ with pytest.raises(ValueError, match=r"must be identical"):
+ adapter.encode_output_state(_media_batch(), {"image_ids": condition_ids})
+
+
+def test_kontext_condition_encoding_uses_explicit_posterior_argmax() -> None:
+ """Cached input conditions remain deterministic while output targets sample."""
+ processor = _Processor()
+ vae = _ConvVAE()
+ pipeline = _FluxPipeline(processor, vae)
+ adapter, _ = _install_runtime(Flux1KontextAdapter, pipeline=pipeline, vae=vae)
+ adapter._standardize_image_input = lambda images, output_type: images
+ generator = torch.Generator().manual_seed(29)
+ images = [Image.new("RGB", (WIDTH, HEIGHT)), Image.new("RGB", (WIDTH, HEIGHT))]
+
+ condition = adapter.encode_image(
+ images,
+ condition_image_size=(HEIGHT, WIDTH),
+ generator=generator,
+ )
+
+ posterior = vae.posteriors[0]
+ assert posterior.mode_calls == 1
+ assert posterior.sample_generators == []
+ assert condition["image_latents"].shape == (2, 4, 8)
+ assert condition["image_ids"].shape == (2, 4, 3)
+ assert torch.equal(condition["image_ids"][..., 0], torch.ones(2, 4))
+
+
+def test_z_image_keeps_precision_aware_transformer_loading() -> None:
+ """Offline codec support does not weaken the precision branch's model contract."""
+ assert ZImageAdapter.component_load_dtype_defaults == {"transformer": torch.float32}
From db4a73f060e28ee578972cb3344e90f6e2b8a6fe Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:36:16 +0800
Subject: [PATCH 13/76] feat(models): add modern image output codecs
---
src/flow_factory/models/flux/flux2.py | 43 +-
src/flow_factory/models/flux/flux2_klein.py | 42 +-
src/flow_factory/models/qwen_image/_output.py | 71 ++-
.../models/qwen_image/qwen_image.py | 41 +-
.../models/qwen_image/qwen_image_edit_plus.py | 229 +++++++++-
.../models/test_modern_image_output_codecs.py | 417 ++++++++++++++++++
6 files changed, 780 insertions(+), 63 deletions(-)
create mode 100644 tests/models/test_modern_image_output_codecs.py
diff --git a/src/flow_factory/models/flux/flux2.py b/src/flow_factory/models/flux/flux2.py
index db1c3df4a..16b1b6d99 100644
--- a/src/flow_factory/models/flux/flux2.py
+++ b/src/flow_factory/models/flux/flux2.py
@@ -19,7 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
@@ -36,6 +36,7 @@
)
from PIL import Image
+from ...contracts import InputMediaOrder, NegativePromptPolicy
from ...hparams import *
from ...samples import I2ISample
from ...scheduler import (
@@ -63,6 +64,12 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import (
+ ConfiguredImageOutputAdapterMixin,
+ EncodedImageTensor,
+)
+from ..pipeline_contracts import image_output_contract
+from ._output import encode_flux2_output_images, prepare_flux2_condition_latents
logger = setup_logger(__name__)
@@ -83,7 +90,14 @@ class Flux2Sample(I2ISample):
CONDITION_IMAGE_SIZE = (1024, 1024)
-class Flux2Adapter(BaseAdapter):
+class Flux2Adapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_image_min_count=0,
+ input_image_max_count=None,
+ input_order=InputMediaOrder.WITHIN_TYPE,
+ )
+
def __init__(self, config: Arguments, accelerator: Accelerator):
super().__init__(config, accelerator)
self.pipeline: Flux2Pipeline
@@ -95,8 +109,7 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
def load_pipeline(self) -> Flux2Pipeline:
return self._load_diffusers_pipeline(
- Flux2Pipeline,
- self.model_args.model_name_or_path, low_cpu_mem_usage=False
+ Flux2Pipeline, self.model_args.model_name_or_path, low_cpu_mem_usage=False
)
@property
@@ -259,12 +272,12 @@ def encode_image(
image_latents_list = []
image_latent_ids_list = []
for cond_img_tensors in condition_image_tensors:
- image_latents, image_latent_ids = self.pipeline.prepare_image_latents(
- images=cond_img_tensors,
+ image_latents, image_latent_ids = prepare_flux2_condition_latents(
+ self,
+ cond_img_tensors,
batch_size=1,
device=device,
dtype=dtype,
- generator=generator,
)
image_latents_list.append(image_latents.squeeze(0))
image_latent_ids_list.append(image_latent_ids.squeeze(0))
@@ -377,7 +390,21 @@ def _standardize_image_input(
# ------------------------- Video Encoding ------------------------
def encode_video(self, videos: Any) -> None:
"""Flux.2 does not support video encoding."""
- pass
+ return None
+
+ def _output_geometry_multiple(self) -> int:
+ """Require the VAE grid and 2x2 latent patching used by Diffusers."""
+ return self.pipeline.vae_scale_factor * 2
+
+ def _encode_output_images(
+ self,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ ) -> EncodedImageTensor:
+ """Sample and pack target images with the FLUX.2 latent recipe."""
+ del condition
+ return encode_flux2_output_images(self, pixel_values, generator)
# ------------------------- Latent Decoding ------------------------
def decode_latents(
diff --git a/src/flow_factory/models/flux/flux2_klein.py b/src/flow_factory/models/flux/flux2_klein.py
index faa4f57ff..c49dfa343 100644
--- a/src/flow_factory/models/flux/flux2_klein.py
+++ b/src/flow_factory/models/flux/flux2_klein.py
@@ -19,7 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
@@ -28,6 +28,7 @@
from PIL import Image
from transformers import Qwen2TokenizerFast, Qwen3ForCausalLM
+from ...contracts import InputMediaOrder, NegativePromptPolicy
from ...hparams import *
from ...samples import I2ISample
from ...scheduler import (
@@ -55,6 +56,12 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import (
+ ConfiguredImageOutputAdapterMixin,
+ EncodedImageTensor,
+)
+from ..pipeline_contracts import image_output_contract
+from ._output import encode_flux2_output_images, prepare_flux2_condition_latents
logger = setup_logger(__name__)
@@ -76,8 +83,14 @@ class Flux2KleinSample(I2ISample):
CONDITION_IMAGE_SIZE = (1024, 1024)
-class Flux2KleinAdapter(BaseAdapter):
+class Flux2KleinAdapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
supports_diffusers_cache = True
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ input_image_min_count=0,
+ input_image_max_count=None,
+ input_order=InputMediaOrder.WITHIN_TYPE,
+ )
def __init__(self, config: Arguments, accelerator: Accelerator):
super().__init__(config, accelerator)
@@ -89,8 +102,7 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
def load_pipeline(self) -> Flux2KleinPipeline:
return self._load_diffusers_pipeline(
- Flux2KleinPipeline,
- self.model_args.model_name_or_path, low_cpu_mem_usage=False
+ Flux2KleinPipeline, self.model_args.model_name_or_path, low_cpu_mem_usage=False
)
@property
@@ -268,10 +280,10 @@ def encode_image(
image_latents_list = []
image_latent_ids_list = []
for cond_img_tensors in condition_image_tensors:
- image_latents, image_latent_ids = self.pipeline.prepare_image_latents(
- images=cond_img_tensors,
+ image_latents, image_latent_ids = prepare_flux2_condition_latents(
+ self,
+ cond_img_tensors,
batch_size=1,
- generator=generator,
device=device,
dtype=dtype,
)
@@ -376,7 +388,21 @@ def _resize_condition_images(
# ------------------------- Video Encoding ------------------------
def encode_video(self, videos: Any) -> None:
"""Flux.2 does not support video encoding."""
- pass
+ return None
+
+ def _output_geometry_multiple(self) -> int:
+ """Require the VAE grid and 2x2 latent patching used by Diffusers."""
+ return self.pipeline.vae_scale_factor * 2
+
+ def _encode_output_images(
+ self,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ ) -> EncodedImageTensor:
+ """Sample and pack target images with the FLUX.2 Klein latent recipe."""
+ del condition
+ return encode_flux2_output_images(self, pixel_values, generator)
# ============================== Decode Latents =========================================
diff --git a/src/flow_factory/models/qwen_image/_output.py b/src/flow_factory/models/qwen_image/_output.py
index a9b585f4e..0c0858463 100644
--- a/src/flow_factory/models/qwen_image/_output.py
+++ b/src/flow_factory/models/qwen_image/_output.py
@@ -17,6 +17,7 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
+from numbers import Integral
from typing import Any, Optional
import torch
@@ -112,7 +113,7 @@ def encode_qwen_output_images(
img_shapes = [[target_shape] for _ in range(batch_size)]
if condition_sizes_key is not None:
condition_sizes = condition.get(condition_sizes_key)
- parsed_sizes = _parse_condition_sizes(
+ parsed_sizes = parse_qwen_condition_sizes(
condition_sizes,
batch_size=batch_size,
source=f"{type(adapter).__name__} condition[{condition_sizes_key!r}]",
@@ -152,38 +153,74 @@ def _channel_statistics(
return statistics.view(1, channels, 1, 1, 1)
-def _parse_condition_sizes(
+def parse_qwen_condition_sizes(
value: Any,
*,
batch_size: int,
source: str,
) -> list[list[tuple[int, int]]]:
- """Validate collated per-sample ``(width, height)`` condition geometry."""
- if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
- raise TypeError(f"{source} must be a per-sample sequence, received {type(value).__name__}")
- if len(value) != batch_size:
+ """Normalize dense or ragged collated ``(width, height)`` geometry."""
+ samples = _geometry_sequence(value, source=source, expected_rank=3)
+ if len(samples) != batch_size:
raise ValueError(
- f"{source} expected batch size {batch_size}, received sequence length {len(value)}"
+ f"{source} expected batch size {batch_size}, received sequence length {len(samples)}"
)
result: list[list[tuple[int, int]]] = []
- for sample_index, sizes in enumerate(value):
- if not isinstance(sizes, Sequence) or isinstance(sizes, (str, bytes)):
- raise TypeError(f"{source}[{sample_index}] must be a sequence of (width, height) pairs")
+ for sample_index, sizes in enumerate(samples):
+ sample_source = f"{source}[{sample_index}]"
+ sizes = _geometry_sequence(sizes, source=sample_source, expected_rank=2)
parsed: list[tuple[int, int]] = []
for size_index, size in enumerate(sizes):
- if not isinstance(size, Sequence) or isinstance(size, (str, bytes)) or len(size) != 2:
+ size_source = f"{sample_source}[{size_index}]"
+ size = _geometry_sequence(size, source=size_source, expected_rank=1)
+ if len(size) != 2:
raise TypeError(
- f"{source}[{sample_index}][{size_index}] must be a (width, height) pair"
+ f"{size_source} must be a (width, height) pair, received length {len(size)}"
)
width, height = size
- if type(width) is not int or type(height) is not int or width <= 0 or height <= 0:
+ if (
+ isinstance(width, bool)
+ or not isinstance(width, Integral)
+ or isinstance(height, bool)
+ or not isinstance(height, Integral)
+ or width <= 0
+ or height <= 0
+ ):
raise ValueError(
- f"{source}[{sample_index}][{size_index}] expected positive integer "
- f"geometry, received {tuple(size)!r}"
+ f"{size_source} expected positive integer geometry, "
+ f"received {tuple(size)!r}"
)
- parsed.append((width, height))
+ parsed.append((int(width), int(height)))
result.append(parsed)
return result
-__all__ = ["encode_qwen_output_images", "encode_qwen_vae_image"]
+def _geometry_sequence(value: Any, *, source: str, expected_rank: int) -> list[Any]:
+ """Convert one tensor/sequence geometry level without losing item order."""
+ if isinstance(value, torch.Tensor):
+ if value.ndim != expected_rank:
+ raise ValueError(
+ f"{source} expected rank {expected_rank}, received tensor shape "
+ f"{tuple(value.shape)}"
+ )
+ if value.dtype not in (
+ torch.uint8,
+ torch.int8,
+ torch.int16,
+ torch.int32,
+ torch.int64,
+ ):
+ raise TypeError(
+ f"{source} expected integer geometry tensor, received dtype {value.dtype}"
+ )
+ return value.detach().cpu().tolist()
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise TypeError(f"{source} must be a sequence, received {type(value).__name__}")
+ return list(value)
+
+
+__all__ = [
+ "encode_qwen_output_images",
+ "encode_qwen_vae_image",
+ "parse_qwen_condition_sizes",
+]
diff --git a/src/flow_factory/models/qwen_image/qwen_image.py b/src/flow_factory/models/qwen_image/qwen_image.py
index 35ead8230..80424f70f 100644
--- a/src/flow_factory/models/qwen_image/qwen_image.py
+++ b/src/flow_factory/models/qwen_image/qwen_image.py
@@ -19,7 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import torch
from accelerate import Accelerator
@@ -29,6 +29,7 @@
import diffusers
+from ...contracts import NegativePromptPolicy
from ...hparams import *
from ...samples import T2ISample
from ...scheduler import (
@@ -48,6 +49,12 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import (
+ ConfiguredImageOutputAdapterMixin,
+ EncodedImageTensor,
+)
+from ..pipeline_contracts import image_output_contract
+from ._output import encode_qwen_output_images
from ._utils import _pad_seq_dim
logger = setup_logger(__name__)
@@ -65,13 +72,16 @@ class QwenImageSample(T2ISample):
img_shapes: Optional[List[Tuple[int, int, int]]] = None
-class QwenImageAdapter(BaseAdapter):
+class QwenImageAdapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
"""Adapter for Qwen-Image text-to-image models."""
# Qwen-Image runs with guidance=None, so the transformer's guidance embedder
# receives no gradient and DDP must scan for unused parameters.
ddp_find_unused_parameters = True
supports_diffusers_cache = True
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ )
def __init__(self, config: Arguments, accelerator: Accelerator):
if not is_version_at_least("diffusers", "0.37.0"):
@@ -91,8 +101,7 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
def load_pipeline(self) -> QwenImagePipeline:
return self._load_diffusers_pipeline(
- QwenImagePipeline,
- self.model_args.model_name_or_path, low_cpu_mem_usage=False
+ QwenImagePipeline, self.model_args.model_name_or_path, low_cpu_mem_usage=False
)
@property
@@ -232,13 +241,29 @@ def encode_prompt(
return results
- def encode_image(self, image: Union[Image.Image, torch.Tensor, List[torch.Tensor]]):
+ def encode_image(
+ self,
+ image: Union[Image.Image, torch.Tensor, List[torch.Tensor]],
+ ) -> None:
"""Not needed for Qwen-Image text-to-image models."""
- pass
+ return None
- def encode_video(self, video: Union[torch.Tensor, List[torch.Tensor]]):
+ def encode_video(self, video: Union[torch.Tensor, List[torch.Tensor]]) -> None:
"""Not needed for Qwen-Image text-to-image models."""
- pass
+ return None
+
+ def _output_geometry_multiple(self) -> int:
+ """Require the VAE grid and 2x2 latent packing used by Diffusers."""
+ return self.pipeline.vae_scale_factor * 2
+
+ def _encode_output_images(
+ self,
+ pixel_values: torch.Tensor,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator],
+ ) -> EncodedImageTensor:
+ """Sample target images through Qwen's five-dimensional VAE path."""
+ return encode_qwen_output_images(self, pixel_values, condition, generator)
def decode_latents(
self,
diff --git a/src/flow_factory/models/qwen_image/qwen_image_edit_plus.py b/src/flow_factory/models/qwen_image/qwen_image_edit_plus.py
index 4a8f00b03..f2fef752f 100644
--- a/src/flow_factory/models/qwen_image/qwen_image_edit_plus.py
+++ b/src/flow_factory/models/qwen_image/qwen_image_edit_plus.py
@@ -20,7 +20,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
@@ -32,8 +32,15 @@
import diffusers
+from ...contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaOrder,
+ MediaType,
+ NegativePromptPolicy,
+)
from ...hparams import *
-from ...samples import I2ISample
+from ...samples import I2ISample, LatentState
from ...scheduler import (
FlowMatchEulerDiscreteSDEScheduler,
FlowMatchEulerDiscreteSDESchedulerOutput,
@@ -60,6 +67,20 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..configured_image_output import ConfiguredImageOutputCodec
+from ..output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+ OutputStateCodec,
+)
+from ..pipeline_contracts import image_output_contract
+from ._output import (
+ encode_qwen_output_images,
+ encode_qwen_vae_image,
+ parse_qwen_condition_sizes,
+)
from ._utils import _pad_seq_dim
logger = setup_logger(__name__)
@@ -81,21 +102,6 @@ class QwenImageEditPlusSample(I2ISample):
image_latents: Optional[torch.Tensor] = None
-def retrieve_latents(
- encoder_output: torch.Tensor,
- generator: Optional[torch.Generator] = None,
- sample_mode: str = "sample",
-):
- if hasattr(encoder_output, "latent_dist") and sample_mode == "sample":
- return encoder_output.latent_dist.sample(generator)
- elif hasattr(encoder_output, "latent_dist") and sample_mode == "argmax":
- return encoder_output.latent_dist.mode()
- elif hasattr(encoder_output, "latents"):
- return encoder_output.latents
- else:
- raise AttributeError("Could not access latents of provided encoder_output")
-
-
def calculate_dimensions(target_area, ratio):
# Calculate width and height based on target area and aspect ratio (height / width)
height = math.sqrt(target_area * ratio)
@@ -107,6 +113,69 @@ def calculate_dimensions(target_area, ratio):
return width, height
+@dataclass(frozen=True, slots=True)
+class _QwenImageEditOutputStateCodec:
+ """Encode targets at the input-derived geometry used by Qwen Edit Plus."""
+
+ adapter: "QwenImageEditPlusAdapter"
+ required_components: ClassVar[Tuple[str, ...]] = ("vae",)
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Resolve output aspect ratio from the last reference, then VAE encode."""
+ if len(media_batch) != 1:
+ raise ValueError(
+ "Qwen-Image-Edit-Plus output codec supports exactly one sample per batch, "
+ f"received {len(media_batch)}"
+ )
+ images = ConfiguredImageOutputCodec._extract_images(media_batch)
+ height, width = self.adapter._condition_derived_output_geometry(condition)
+ pixel_values = self.adapter.pipeline.image_processor.preprocess(
+ images,
+ height=height,
+ width=width,
+ )
+ ConfiguredImageOutputCodec._validate_pixel_values(
+ pixel_values,
+ 1,
+ height,
+ width,
+ )
+ vae_dtype = getattr(self.adapter.vae, "dtype", None)
+ if not isinstance(vae_dtype, torch.dtype) or not vae_dtype.is_floating_point:
+ raise TypeError(
+ "Qwen-Image-Edit-Plus output codec expected VAE floating dtype, "
+ f"received {vae_dtype!r}"
+ )
+ pixel_values = pixel_values.to(device=self.adapter.device, dtype=vae_dtype)
+ encoded_image = encode_qwen_output_images(
+ self.adapter,
+ pixel_values,
+ condition,
+ generator,
+ condition_sizes_key="vae_image_sizes",
+ )
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=height,
+ width=width,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=LatentState({"latent": encoded_image.latents}),
+ forward_context=encoded_image.forward_context,
+ decode_context={"height": height, "width": width},
+ geometry_signatures=(signature,),
+ )
+
+
class QwenImageEditPlusAdapter(BaseAdapter):
"""Adapter for Qwen-Image-Edit Plus text-to-image models."""
@@ -114,6 +183,14 @@ class QwenImageEditPlusAdapter(BaseAdapter):
# embedder receives no gradient and DDP must scan for unused parameters.
ddp_find_unused_parameters = True
supports_diffusers_cache = True
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ input_image_min_count=1,
+ input_image_max_count=None,
+ input_order=InputMediaOrder.WITHIN_TYPE,
+ geometry_source=GeometrySource.INPUT_MEDIA,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
def __init__(self, config: Arguments, accelerator: Accelerator):
if not is_version_at_least("diffusers", "0.37.0"):
@@ -137,10 +214,112 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
def load_pipeline(self) -> QwenImageEditPlusPipeline:
return self._load_diffusers_pipeline(
- QwenImageEditPlusPipeline,
- self.model_args.model_name_or_path, low_cpu_mem_usage=False
+ QwenImageEditPlusPipeline, self.model_args.model_name_or_path, low_cpu_mem_usage=False
)
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Declare the condition-geometry-aware Qwen target image codec."""
+ return _QwenImageEditOutputStateCodec(self)
+
+ def _condition_derived_output_geometry(
+ self,
+ condition: Mapping[str, Any],
+ ) -> Tuple[int, int]:
+ """Match inference auto-resize using the last reference aspect ratio."""
+ sizes = parse_qwen_condition_sizes(
+ condition.get("condition_image_sizes"),
+ batch_size=1,
+ source="Qwen-Image-Edit-Plus condition_image_sizes",
+ )[0]
+ if not sizes:
+ raise ValueError(
+ "Qwen-Image-Edit-Plus offline output geometry requires at least one "
+ "condition image size"
+ )
+ image_width, image_height = sizes[-1]
+ configured_height = getattr(self.training_args, "height", None)
+ configured_width = getattr(self.training_args, "width", None)
+ if type(configured_height) is not int or type(configured_width) is not int:
+ raise TypeError(
+ "Qwen-Image-Edit-Plus offline output geometry requires configured integer "
+ f"height/width, received {(configured_height, configured_width)!r}"
+ )
+ width, height = calculate_dimensions(
+ configured_height * configured_width,
+ image_height / image_width,
+ )
+ multiple = self.pipeline.vae_scale_factor * 2
+ width = width // multiple * multiple
+ height = height // multiple * multiple
+ if width <= 0 or height <= 0:
+ raise ValueError(
+ "Qwen-Image-Edit-Plus derived non-positive output geometry " f"{(height, width)}"
+ )
+ return height, width
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Verify signatures, decode fields, and target-first ordered image shapes."""
+ height, width = self._condition_derived_output_geometry(condition)
+ expected_signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=height,
+ width=width,
+ ),
+ )
+ )
+ if len(media_batch) != 1 or encoded.geometry_signatures != (expected_signature,):
+ raise ValueError(
+ "Qwen-Image-Edit-Plus encoded geometry must match its single input-derived "
+ f"target geometry {(height, width)}"
+ )
+ if dict(encoded.decode_context) != {"height": height, "width": width}:
+ raise ValueError(
+ "Qwen-Image-Edit-Plus decode_context must exactly match input-derived "
+ f"geometry {(height, width)}, received {dict(encoded.decode_context)!r}"
+ )
+ expected_img_shapes = self._offline_img_shapes(height, width, condition)
+ img_shapes = encoded.forward_context.get("img_shapes")
+ if img_shapes != expected_img_shapes:
+ raise ValueError(
+ "Qwen-Image-Edit-Plus img_shapes must contain target geometry first and "
+ "preserve ordered reference geometry: "
+ f"expected {expected_img_shapes!r}, received {img_shapes!r}"
+ )
+
+ def _offline_img_shapes(
+ self,
+ height: int,
+ width: int,
+ condition: Mapping[str, Any],
+ ) -> List[List[Tuple[int, int, int]]]:
+ """Build the exact target-first shape sequence consumed by the transformer."""
+ sizes = parse_qwen_condition_sizes(
+ condition.get("vae_image_sizes"),
+ batch_size=1,
+ source="Qwen-Image-Edit-Plus vae_image_sizes",
+ )[0]
+ if not sizes:
+ raise ValueError(
+ "Qwen-Image-Edit-Plus offline img_shapes requires at least one VAE image size"
+ )
+ scale = self.pipeline.vae_scale_factor * 2
+ shapes = [(1, height // scale, width // scale)]
+ for size_index, (image_width, image_height) in enumerate(sizes):
+ if image_width % scale or image_height % scale:
+ raise ValueError(
+ "Qwen-Image-Edit-Plus VAE image geometry must be divisible by "
+ f"{scale}, received {(image_height, image_width)} at index {size_index}"
+ )
+ shapes.append((1, image_height // scale, image_width // scale))
+ return [shapes]
+
@property
def default_target_modules(self) -> List[str]:
"""Default LoRA target modules for Qwen-Image-Edit-Plus transformer."""
@@ -453,13 +632,19 @@ def prepare_image_latents(
device: torch.device,
generator: Optional[Union[torch.Generator, List[torch.Generator]]] = None,
) -> torch.Tensor:
+ """Encode condition images with posterior argmax, then pack in input order."""
+ del generator
images = self._standardize_image_input(images, "pt")
all_image_latents = []
for image in images:
image = image.to(device=device, dtype=dtype)
if image.shape[1] != self.pipeline.latent_channels:
- image_latents = self.pipeline._encode_vae_image(image=image, generator=generator)
+ image_latents = encode_qwen_vae_image(
+ self,
+ image,
+ sample_mode="argmax",
+ )
else:
image_latents = image
if batch_size > image_latents.shape[0] and batch_size % image_latents.shape[0] == 0:
@@ -531,9 +716,9 @@ def prepare_latents(
return latents, image_latents
# ---------------------------------------- Video Encoding ---------------------------------- #
- def encode_video(self, videos: Union[torch.Tensor, List[torch.Tensor]]):
+ def encode_video(self, videos: Union[torch.Tensor, List[torch.Tensor]]) -> None:
"""Not needed for Qwen-Image-Edit models."""
- pass
+ return None
# ---------------------------------------- Image Decoding ---------------------------------- #
def decode_latents(
diff --git a/tests/models/test_modern_image_output_codecs.py b/tests/models/test_modern_image_output_codecs.py
new file mode 100644
index 000000000..0a7779b9c
--- /dev/null
+++ b/tests/models/test_modern_image_output_codecs.py
@@ -0,0 +1,417 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Fake-only coverage for FLUX.2 and Qwen offline output codecs."""
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Any, Optional
+
+import pytest
+import torch
+from PIL import Image
+
+from diffusers import Flux2Pipeline, QwenImageEditPlusPipeline
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaOrder,
+ MediaType,
+)
+from flow_factory.models.flux._output import (
+ encode_flux2_vae_image,
+ prepare_flux2_condition_latents,
+)
+from flow_factory.models.flux.flux2 import Flux2Adapter
+from flow_factory.models.flux.flux2_klein import Flux2KleinAdapter
+from flow_factory.models.qwen_image._output import encode_qwen_vae_image
+from flow_factory.models.qwen_image.qwen_image import QwenImageAdapter
+from flow_factory.models.qwen_image.qwen_image_edit_plus import QwenImageEditPlusAdapter
+
+
+@dataclass(frozen=True)
+class _DecodedMedia:
+ type: str
+ payload: Any
+ fps: Optional[float] = None
+ sample_rate: Optional[int] = None
+
+
+class _Processor:
+ def __init__(self) -> None:
+ self.calls: list[tuple[int, int, int, bool]] = []
+
+ def preprocess(
+ self,
+ images: list[Image.Image],
+ *,
+ height: int,
+ width: int,
+ ) -> torch.Tensor:
+ self.calls.append((len(images), height, width, torch.is_grad_enabled()))
+ return torch.arange(
+ len(images) * 3 * height * width,
+ dtype=torch.float32,
+ ).reshape(len(images), 3, height, width)
+
+
+class _Posterior:
+ def __init__(self, value: torch.Tensor) -> None:
+ self.value = value
+ self.mode_calls = 0
+ self.sample_generators: list[Optional[torch.Generator]] = []
+
+ def mode(self) -> torch.Tensor:
+ self.mode_calls += 1
+ return self.value
+
+ def sample(self, generator: Optional[torch.Generator] = None) -> torch.Tensor:
+ self.sample_generators.append(generator)
+ return self.value + 7.0
+
+
+class _Flux2VAE:
+ dtype = torch.bfloat16
+ config = SimpleNamespace(batch_norm_eps=0.0)
+ bn = SimpleNamespace(running_mean=torch.zeros(2), running_var=torch.ones(2))
+
+ def __init__(self) -> None:
+ self.inputs: list[torch.Tensor] = []
+ self.posteriors: list[_Posterior] = []
+
+ def encode(self, image: torch.Tensor) -> Any:
+ self.inputs.append(image)
+ posterior = _Posterior(image[:, :2, ::8, ::8])
+ self.posteriors.append(posterior)
+ return SimpleNamespace(latent_dist=posterior)
+
+
+class _QwenVAE:
+ dtype = torch.float32
+ config = SimpleNamespace(latents_mean=[1.0, 2.0], latents_std=[2.0, 4.0])
+
+ def __init__(self) -> None:
+ self.inputs: list[torch.Tensor] = []
+ self.posteriors: list[_Posterior] = []
+
+ def encode(self, values: torch.Tensor) -> Any:
+ self.inputs.append(values)
+ latent = torch.cat(
+ [
+ values[:, :1, :, ::8, ::8] + 5.0,
+ values[:, 1:2, :, ::8, ::8] + 10.0,
+ ],
+ dim=1,
+ )
+ posterior = _Posterior(latent)
+ self.posteriors.append(posterior)
+ return SimpleNamespace(latent_dist=posterior)
+
+
+class _DeclarationRuntime:
+ materialized_component_names = ()
+ override_components: dict[str, Any] = {}
+ declared_component_names = ("vae",)
+
+
+def _media_batch(batch_size: int = 2) -> tuple[tuple[_DecodedMedia, ...], ...]:
+ return tuple(
+ (
+ _DecodedMedia(
+ type="image",
+ payload=Image.new("RGB", (11 + index, 13 + index)),
+ ),
+ )
+ for index in range(batch_size)
+ )
+
+
+def _install_adapter_runtime(
+ adapter: Any,
+ *,
+ pipeline: Any,
+ vae: Any,
+ height: int = 32,
+ width: int = 32,
+) -> Any:
+ adapter.training_args = SimpleNamespace(
+ height=height,
+ width=width,
+ latent_storage_dtype=None,
+ )
+ adapter.accelerator = SimpleNamespace(device=torch.device("cpu"))
+ adapter.pipeline = pipeline
+ adapter.component_runtime = SimpleNamespace(get_component=lambda name: vae)
+ adapter._output_state_encoding_modules = ("vae",)
+ adapter._output_state_codec = adapter.build_output_state_codec()
+ return adapter
+
+
+def _pack_qwen_latents(
+ latents: torch.Tensor,
+ batch_size: int,
+ channels: int,
+ height: int,
+ width: int,
+) -> torch.Tensor:
+ assert tuple(latents.shape) == (batch_size, channels, 1, height, width)
+ return (
+ latents.reshape(batch_size, channels, 1, height // 2, 2, width // 2, 2)
+ .permute(0, 2, 3, 5, 1, 4, 6)
+ .reshape(batch_size, height // 2 * (width // 2), channels * 4)
+ )
+
+
+@pytest.mark.parametrize(
+ "adapter_cls",
+ [Flux2Adapter, Flux2KleinAdapter, QwenImageAdapter, QwenImageEditPlusAdapter],
+)
+def test_modern_image_codec_declarations_require_only_logical_vae(
+ adapter_cls: type,
+) -> None:
+ """Codec construction is declaration-only and names the logical VAE route."""
+ adapter_cls.validate_offline_output_capability()
+ adapter = object.__new__(adapter_cls)
+ adapter.training_args = SimpleNamespace(height=32, width=32)
+ adapter.pipeline = SimpleNamespace(vae_scale_factor=8)
+ adapter.component_runtime = _DeclarationRuntime()
+
+ codec = adapter._build_output_state_codec_declaration()
+ adapter._output_state_codec = codec
+
+ assert codec is not None
+ assert codec.required_components == ("vae",)
+ assert adapter._validate_output_state_codec_lifecycle() == ("vae",)
+ assert adapter.component_runtime.materialized_component_names == ()
+ assert adapter.component_runtime.override_components == {}
+
+
+@pytest.mark.parametrize("adapter_cls", [Flux2Adapter, Flux2KleinAdapter])
+def test_flux2_target_samples_while_condition_encoding_uses_argmax(
+ adapter_cls: type,
+) -> None:
+ """FLUX.2 shares patchify/BN/packing but keeps role-specific posterior policy."""
+ processor = _Processor()
+ vae = _Flux2VAE()
+
+ def pack(latents: torch.Tensor) -> torch.Tensor:
+ return latents.flatten(2).transpose(1, 2)
+
+ pipeline = SimpleNamespace(
+ image_processor=processor,
+ vae_scale_factor=8,
+ _patchify_latents=lambda latents: latents,
+ _prepare_latent_ids=lambda latents: torch.zeros(
+ latents.shape[0],
+ latents.shape[-2] * latents.shape[-1],
+ 4,
+ ),
+ _prepare_image_ids=lambda latents: torch.zeros(
+ 1,
+ sum(item.shape[-2] * item.shape[-1] for item in latents),
+ 4,
+ ),
+ _pack_latents=pack,
+ )
+ adapter = _install_adapter_runtime(
+ object.__new__(adapter_cls),
+ pipeline=pipeline,
+ vae=vae,
+ )
+ generator = torch.Generator().manual_seed(17)
+
+ encoded = adapter.encode_output_state(_media_batch(), {}, generator)
+
+ assert vae.inputs[0].dtype is torch.bfloat16
+ assert vae.posteriors[0].sample_generators == [generator]
+ assert vae.posteriors[0].mode_calls == 0
+ assert encoded.clean_state.components["latent"].shape == (2, 16, 2)
+ assert encoded.forward_context["latent_ids"] is encoded.decode_context["latent_ids"]
+
+ condition_latents, condition_ids = prepare_flux2_condition_latents(
+ adapter,
+ [torch.zeros(1, 3, 32, 32)],
+ batch_size=1,
+ device=torch.device("cpu"),
+ dtype=torch.bfloat16,
+ )
+
+ assert vae.posteriors[1].mode_calls == 1
+ assert vae.posteriors[1].sample_generators == []
+ assert condition_latents.shape == (1, 16, 2)
+ assert condition_ids.shape == (1, 16, 4)
+
+
+def test_flux2_condition_transform_matches_pinned_diffusers() -> None:
+ """The role-neutral FLUX.2 argmax transform stays aligned with Diffusers."""
+ pixels = torch.randn(2, 3, 32, 32, dtype=torch.bfloat16)
+ expected_vae = _Flux2VAE()
+ expected = Flux2Pipeline._encode_vae_image(
+ SimpleNamespace(
+ vae=expected_vae,
+ _patchify_latents=lambda latents: latents,
+ ),
+ pixels,
+ torch.Generator().manual_seed(31),
+ )
+ actual_vae = _Flux2VAE()
+ actual = encode_flux2_vae_image(
+ SimpleNamespace(
+ vae=actual_vae,
+ pipeline=SimpleNamespace(_patchify_latents=lambda latents: latents),
+ ),
+ pixels,
+ sample_mode="argmax",
+ )
+
+ assert torch.equal(actual, expected)
+ assert actual_vae.posteriors[0].mode_calls == 1
+
+
+def test_qwen_target_codec_samples_five_dimensional_latents() -> None:
+ """Qwen T2I targets sample the posterior before normalization and 2x2 packing."""
+ processor = _Processor()
+ vae = _QwenVAE()
+ pipeline = SimpleNamespace(
+ image_processor=processor,
+ vae_scale_factor=8,
+ _pack_latents=_pack_qwen_latents,
+ )
+ adapter = _install_adapter_runtime(
+ object.__new__(QwenImageAdapter),
+ pipeline=pipeline,
+ vae=vae,
+ )
+ generator = torch.Generator().manual_seed(19)
+
+ encoded = adapter.encode_output_state(_media_batch(), {}, generator)
+
+ assert vae.inputs[0].shape == (2, 3, 1, 32, 32)
+ assert vae.posteriors[0].sample_generators == [generator]
+ assert vae.posteriors[0].mode_calls == 0
+ assert encoded.clean_state.components["latent"].shape == (2, 4, 8)
+ assert encoded.forward_context["img_shapes"] == [[(1, 2, 2)], [(1, 2, 2)]]
+
+
+def test_qwen_condition_transform_matches_pinned_diffusers() -> None:
+ """The role-neutral Qwen argmax transform stays aligned with Diffusers."""
+ pixels = torch.randn(2, 3, 1, 32, 32)
+ expected_vae = _QwenVAE()
+ expected = QwenImageEditPlusPipeline._encode_vae_image(
+ SimpleNamespace(vae=expected_vae, latent_channels=2),
+ pixels,
+ torch.Generator().manual_seed(37),
+ )
+ actual_vae = _QwenVAE()
+ actual = encode_qwen_vae_image(
+ SimpleNamespace(vae=actual_vae),
+ pixels,
+ sample_mode="argmax",
+ )
+
+ assert torch.equal(actual, expected)
+ assert actual_vae.posteriors[0].mode_calls == 1
+
+
+def test_qwen_edit_keeps_condition_argmax_and_target_first_ordered_shapes() -> None:
+ """Qwen Edit separates posterior roles and retains ordered reference geometry."""
+ processor = _Processor()
+ vae = _QwenVAE()
+ pipeline = SimpleNamespace(
+ image_processor=processor,
+ vae_scale_factor=8,
+ latent_channels=2,
+ _pack_latents=_pack_qwen_latents,
+ )
+ adapter = _install_adapter_runtime(
+ object.__new__(QwenImageEditPlusAdapter),
+ pipeline=pipeline,
+ vae=vae,
+ height=64,
+ width=64,
+ )
+
+ condition_latents = adapter.prepare_image_latents(
+ images=[
+ torch.zeros(1, 3, 1, 32, 32),
+ torch.full((1, 3, 1, 32, 64), 10.0),
+ ],
+ batch_size=1,
+ num_channels_latents=2,
+ dtype=torch.float32,
+ device=torch.device("cpu"),
+ generator=torch.Generator().manual_seed(23),
+ )
+
+ assert condition_latents.shape == (1, 12, 8)
+ assert [posterior.mode_calls for posterior in vae.posteriors] == [1, 1]
+ assert all(not posterior.sample_generators for posterior in vae.posteriors)
+
+ condition = {
+ "condition_image_sizes": torch.tensor([[[32, 32], [64, 32]]]),
+ "vae_image_sizes": torch.tensor([[[32, 32], [64, 32]]]),
+ }
+ generator = torch.Generator().manual_seed(29)
+ encoded = adapter.encode_output_state(_media_batch(1), condition, generator)
+
+ assert vae.posteriors[2].sample_generators == [generator]
+ assert vae.posteriors[2].mode_calls == 0
+ assert processor.calls == [(1, 32, 96, False)]
+ assert dict(encoded.decode_context) == {"height": 32, "width": 96}
+ assert encoded.forward_context["img_shapes"] == [[(1, 2, 6), (1, 2, 2), (1, 2, 4)]]
+
+
+@pytest.mark.parametrize(
+ ("adapter_cls", "geometry_source", "batch_capability", "input_order"),
+ [
+ (
+ Flux2Adapter,
+ GeometrySource.CONFIGURED,
+ BatchCapability.UNIFORM,
+ InputMediaOrder.WITHIN_TYPE,
+ ),
+ (
+ Flux2KleinAdapter,
+ GeometrySource.CONFIGURED,
+ BatchCapability.UNIFORM,
+ InputMediaOrder.WITHIN_TYPE,
+ ),
+ (
+ QwenImageAdapter,
+ GeometrySource.CONFIGURED,
+ BatchCapability.UNIFORM,
+ InputMediaOrder.INSENSITIVE,
+ ),
+ (
+ QwenImageEditPlusAdapter,
+ GeometrySource.INPUT_MEDIA,
+ BatchCapability.SINGLE_SAMPLE,
+ InputMediaOrder.WITHIN_TYPE,
+ ),
+ ],
+)
+def test_modern_image_pipeline_contracts_are_explicit(
+ adapter_cls: type,
+ geometry_source: GeometrySource,
+ batch_capability: BatchCapability,
+ input_order: InputMediaOrder,
+) -> None:
+ """Algorithm code can reason about media and batching without adapter checks."""
+ contract = adapter_cls.pipeline_io_contract
+
+ assert contract is not None
+ assert tuple(item.type for item in contract.output_media.items) == (MediaType.IMAGE,)
+ assert contract.geometry_source is geometry_source
+ assert contract.batch_capability is batch_capability
+ assert contract.input_media.order is input_order
From a424f7958af7a7908bd8c9a44cbd2fd44cf073cd Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:39:09 +0800
Subject: [PATCH 14/76] feat(models): add Bagel output codec
---
src/flow_factory/models/bagel/__init__.py | 15 +-
src/flow_factory/models/bagel/_output.py | 522 ++++++++++++++++++
src/flow_factory/models/bagel/bagel.py | 45 ++
.../models/bagel/modeling/autoencoder.py | 49 +-
tests/models/test_all_model_loaders.py | 3 +
tests/models/test_bagel_output_codec.py | 383 +++++++++++++
6 files changed, 1008 insertions(+), 9 deletions(-)
create mode 100644 src/flow_factory/models/bagel/_output.py
create mode 100644 tests/models/test_bagel_output_codec.py
diff --git a/src/flow_factory/models/bagel/__init__.py b/src/flow_factory/models/bagel/__init__.py
index 0d2524b18..c17a09510 100644
--- a/src/flow_factory/models/bagel/__init__.py
+++ b/src/flow_factory/models/bagel/__init__.py
@@ -20,8 +20,10 @@
Supports Text-to-Image and Image(s)-to-Image generation tasks.
"""
-from .bagel import BagelAdapter, BagelI2ISample, BagelSample
-from .pipeline import BagelPseudoPipeline
+from __future__ import annotations
+
+from importlib import import_module
+from typing import Any
__all__ = [
"BagelAdapter",
@@ -29,3 +31,12 @@
"BagelI2ISample",
"BagelPseudoPipeline",
]
+
+
+def __getattr__(name: str) -> Any:
+ """Load optional-kernel Bagel classes only when callers request them."""
+ if name in {"BagelAdapter", "BagelSample", "BagelI2ISample"}:
+ return getattr(import_module(".bagel", __name__), name)
+ if name == "BagelPseudoPipeline":
+ return getattr(import_module(".pipeline", __name__), name)
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/flow_factory/models/bagel/_output.py b/src/flow_factory/models/bagel/_output.py
new file mode 100644
index 000000000..c98ee1509
--- /dev/null
+++ b/src/flow_factory/models/bagel/_output.py
@@ -0,0 +1,522 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bagel target-image encoding without optional attention dependencies."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from numbers import Integral, Real
+from typing import Any, ClassVar, Literal, Optional, Tuple
+
+import torch
+import torch.nn as nn
+from PIL import Image
+
+from ...contracts import MediaType
+from ...samples import LatentState
+from ..output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+from .data.data_utils import pil_img2rgb
+
+BagelPosteriorMode = Literal["sample", "argmax"]
+
+
+@dataclass(frozen=True, slots=True)
+class BagelOutputStateCodec:
+ """Encode target images through Bagel's custom VAE and packed token layout."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("bagel", "vae")
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Transform, sample, normalize, and patchify target images on demand.
+
+ Args:
+ media_batch: One decoded target image per sample.
+ condition: Input condition paired with the targets. Bagel output encoding
+ does not derive target state from the condition.
+ generator: Optional generator forwarded to posterior sampling.
+
+ Returns:
+ Packed clean target state with output-derived image geometry.
+ """
+ del condition
+ transformed_images, image_shape = _transform_target_images(
+ self.adapter,
+ media_batch,
+ )
+ vae = self.adapter.vae
+ pixel_values = torch.stack(transformed_images).to(
+ device=self.adapter.device,
+ dtype=_module_dtype(vae),
+ )
+ latents = encode_bagel_vae_image(
+ vae,
+ pixel_values,
+ posterior_mode="sample",
+ generator=generator,
+ )
+
+ bagel = self.adapter.get_component("bagel")
+ layout = resolve_bagel_latent_layout(bagel)
+ packed_latents = pack_bagel_latents(
+ latents,
+ image_shape=image_shape,
+ layout=layout,
+ )
+
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=image_shape[0],
+ width=image_shape[1],
+ ),
+ )
+ )
+ context = {"image_shape": image_shape}
+ return EncodedOutputState(
+ clean_state=LatentState({"latent": packed_latents}),
+ forward_context=context,
+ decode_context=context,
+ geometry_signatures=tuple(signature for _ in media_batch),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class BagelLatentLayout:
+ """Describe Bagel's VAE-to-token geometry."""
+
+ patch_size: int
+ channels: int
+ downsample: int
+
+
+def encode_bagel_vae_image(
+ vae: Any,
+ pixel_values: torch.Tensor,
+ *,
+ posterior_mode: BagelPosteriorMode,
+ generator: Optional[torch.Generator] = None,
+) -> torch.Tensor:
+ """Apply explicit posterior selection and Bagel latent normalization.
+
+ This is the role-neutral numerical primitive for Bagel's custom VAE. Existing
+ condition encoding selects the posterior mean through ``vae.reg.sample=False``;
+ target encoding calls this primitive with ``posterior_mode='sample'`` so it does
+ not mutate that global condition policy.
+
+ Args:
+ vae: Bagel custom VAE exposing ``encoder``, ``reg``, ``scale_factor``, and
+ ``shift_factor``.
+ pixel_values: Floating BCHW images in Bagel's official input range.
+ posterior_mode: Explicit posterior selection for the calling role.
+ generator: Optional generator used only when sampling.
+
+ Returns:
+ Shifted and scaled BCHW clean latents.
+ """
+ if not isinstance(pixel_values, torch.Tensor) or pixel_values.ndim != 4:
+ shape = tuple(pixel_values.shape) if isinstance(pixel_values, torch.Tensor) else None
+ raise ValueError(
+ "Bagel VAE target encoding expected a rank-4 BCHW tensor, "
+ f"received {type(pixel_values).__name__} with shape {shape}"
+ )
+ if not pixel_values.is_floating_point():
+ raise TypeError(
+ "Bagel VAE target encoding expected floating pixel values, "
+ f"received {pixel_values.dtype}"
+ )
+ if type(posterior_mode) is not str:
+ raise TypeError(
+ "Bagel VAE posterior_mode must be str, "
+ f"received {type(posterior_mode).__name__}: {posterior_mode!r}"
+ )
+ if posterior_mode not in ("sample", "argmax"):
+ raise ValueError(
+ "Bagel VAE posterior_mode must be 'sample' or 'argmax', " f"received {posterior_mode!r}"
+ )
+ encoder = getattr(vae, "encoder", None)
+ if not callable(encoder):
+ raise TypeError("Bagel custom VAE must expose a callable encoder")
+ moments = encoder(pixel_values)
+ if not isinstance(moments, torch.Tensor) or moments.ndim != 4:
+ shape = tuple(moments.shape) if isinstance(moments, torch.Tensor) else None
+ raise TypeError(
+ "Bagel custom VAE encoder expected a rank-4 tensor of mean/logvar moments, "
+ f"received {type(moments).__name__} with shape {shape}"
+ )
+
+ reg = getattr(vae, "reg", None)
+ chunk_dim = getattr(reg, "chunk_dim", None)
+ if not isinstance(chunk_dim, Integral) or isinstance(chunk_dim, bool):
+ raise TypeError(
+ "Bagel custom VAE reg.chunk_dim must be an integer, " f"received {chunk_dim!r}"
+ )
+ chunk_dim = int(chunk_dim)
+ if chunk_dim < -moments.ndim or chunk_dim >= moments.ndim:
+ raise ValueError(
+ f"Bagel custom VAE reg.chunk_dim {chunk_dim} is invalid for rank {moments.ndim}"
+ )
+ normalized_chunk_dim = chunk_dim % moments.ndim
+ if moments.shape[normalized_chunk_dim] % 2:
+ raise ValueError(
+ "Bagel custom VAE encoder mean/logvar dimension must be even, "
+ f"received shape {tuple(moments.shape)} at dim {chunk_dim}"
+ )
+ if not moments.is_floating_point():
+ raise TypeError(
+ "Bagel custom VAE encoder moments must be floating, " f"received {moments.dtype}"
+ )
+
+ selector = getattr(reg, "select", None)
+ if not callable(selector):
+ raise TypeError(
+ "Bagel custom VAE reg must expose callable select() for explicit " "posterior policy"
+ )
+ latents = selector(
+ moments,
+ sample=posterior_mode == "sample",
+ generator=generator,
+ )
+ if not isinstance(latents, torch.Tensor) or latents.ndim != moments.ndim:
+ shape = tuple(latents.shape) if isinstance(latents, torch.Tensor) else None
+ raise TypeError(
+ "Bagel custom VAE posterior selection expected a rank-4 tensor, "
+ f"received {type(latents).__name__} with shape {shape}"
+ )
+
+ shift = _finite_real(getattr(vae, "shift_factor", None), "vae.shift_factor")
+ scale = _finite_real(getattr(vae, "scale_factor", None), "vae.scale_factor")
+ if scale <= 0:
+ raise ValueError(f"Bagel vae.scale_factor must be positive, received {scale!r}")
+ normalize_latents = getattr(vae, "normalize_latents", None)
+ if not callable(normalize_latents):
+ raise TypeError(
+ "Bagel custom VAE must expose callable normalize_latents() so condition "
+ "and target roles share shift/scale math"
+ )
+ normalized = normalize_latents(latents)
+ if not isinstance(normalized, torch.Tensor) or normalized.shape != latents.shape:
+ shape = tuple(normalized.shape) if isinstance(normalized, torch.Tensor) else None
+ raise TypeError(
+ "Bagel custom VAE normalize_latents expected a shape-preserving tensor, "
+ f"received {type(normalized).__name__} with shape {shape}"
+ )
+ return normalized
+
+
+def resolve_bagel_latent_layout(bagel: Any) -> BagelLatentLayout:
+ """Validate the Bagel module's official two-by-two latent token layout.
+
+ Args:
+ bagel: Logical Bagel component exposing latent-layout attributes.
+
+ Returns:
+ Validated latent layout.
+ """
+ patch_size = _positive_int(
+ getattr(bagel, "latent_patch_size", None),
+ "bagel.latent_patch_size",
+ )
+ if patch_size != 2:
+ raise ValueError(
+ "Bagel offline output encoding supports latent_patch_size=2, " f"received {patch_size}"
+ )
+ return BagelLatentLayout(
+ patch_size=patch_size,
+ channels=_positive_int(
+ getattr(bagel, "latent_channel", None),
+ "bagel.latent_channel",
+ ),
+ downsample=_positive_int(
+ getattr(bagel, "latent_downsample", None),
+ "bagel.latent_downsample",
+ ),
+ )
+
+
+def pack_bagel_latents(
+ latents: torch.Tensor,
+ *,
+ image_shape: Tuple[int, int],
+ layout: BagelLatentLayout,
+) -> torch.Tensor:
+ """Crop and pack BCHW latents into Bagel's ``B,N,patch^2*C`` order.
+
+ Args:
+ latents: Shifted and scaled Bagel VAE latents.
+ image_shape: Post-transform target height and width.
+ layout: Validated Bagel latent layout.
+
+ Returns:
+ Packed clean target tokens.
+ """
+ if not isinstance(latents, torch.Tensor) or latents.ndim != 4:
+ shape = tuple(latents.shape) if isinstance(latents, torch.Tensor) else None
+ raise ValueError(
+ "Bagel target packing expected rank-4 BCHW latents, "
+ f"received {type(latents).__name__} with shape {shape}"
+ )
+ if latents.shape[1] != layout.channels:
+ raise ValueError(
+ "Bagel custom VAE channel count disagrees with bagel.latent_channel: "
+ f"expected {layout.channels}, received shape {tuple(latents.shape)}"
+ )
+ height, width = image_shape
+ if height % layout.downsample or width % layout.downsample:
+ raise ValueError(
+ "Bagel target geometry must be divisible by bagel.latent_downsample "
+ f"{layout.downsample}, received {image_shape}"
+ )
+ token_height = height // layout.downsample
+ token_width = width // layout.downsample
+ required_height = token_height * layout.patch_size
+ required_width = token_width * layout.patch_size
+ if latents.shape[-2] < required_height or latents.shape[-1] < required_width:
+ raise ValueError(
+ "Bagel custom VAE output is too small for the official crop/patchify path: "
+ f"required at least {(required_height, required_width)}, received "
+ f"{tuple(latents.shape[-2:])}"
+ )
+
+ cropped = latents[:, :, :required_height, :required_width]
+ batch_size, channels = cropped.shape[:2]
+ patch_size = layout.patch_size
+ return (
+ cropped.reshape(
+ batch_size,
+ channels,
+ token_height,
+ patch_size,
+ token_width,
+ patch_size,
+ )
+ .permute(0, 2, 4, 3, 5, 1)
+ .reshape(
+ batch_size,
+ token_height * token_width,
+ patch_size * patch_size * channels,
+ )
+ )
+
+
+def validate_bagel_encoded_output_geometry(
+ adapter: Any,
+ media_batch: DecodedMediaBatch,
+ encoded: EncodedOutputState,
+) -> None:
+ """Verify output-derived geometry against Bagel's transform and token grid.
+
+ Args:
+ adapter: Bagel adapter exposing transform and logical components.
+ media_batch: Decoded target image batch.
+ encoded: Codec result after generic boundary validation.
+ """
+ expected_shapes = tuple(
+ _resized_image_shape(adapter.vae_transform, candidate[0].payload, sample_index)
+ for sample_index, candidate in enumerate(media_batch)
+ )
+ image_shape = expected_shapes[0]
+ if any(shape != image_shape for shape in expected_shapes[1:]):
+ raise ValueError(
+ "Bagel output geometry validation expected a uniform transformed batch, "
+ f"received {expected_shapes}"
+ )
+
+ expected_signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=image_shape[0],
+ width=image_shape[1],
+ ),
+ )
+ )
+ expected_signatures = tuple(expected_signature for _ in media_batch)
+ if encoded.geometry_signatures != expected_signatures:
+ raise ValueError(
+ "Bagel encoded output signatures must match output-media-derived geometry "
+ f"{image_shape}, received {encoded.geometry_signatures!r}"
+ )
+
+ expected_context = {"image_shape": image_shape}
+ if dict(encoded.forward_context) != expected_context:
+ raise ValueError(
+ "Bagel forward_context must contain only the output-derived image_shape "
+ f"{image_shape}, received {dict(encoded.forward_context)!r}"
+ )
+ if dict(encoded.decode_context) != expected_context:
+ raise ValueError(
+ "Bagel decode_context must contain only the output-derived image_shape "
+ f"{image_shape}, received {dict(encoded.decode_context)!r}"
+ )
+
+ layout = resolve_bagel_latent_layout(adapter.get_component("bagel"))
+ if image_shape[0] % layout.downsample or image_shape[1] % layout.downsample:
+ raise ValueError(
+ "Bagel encoded output geometry must be divisible by bagel.latent_downsample "
+ f"{layout.downsample}, received {image_shape}"
+ )
+ expected_shape = (
+ len(media_batch),
+ (image_shape[0] // layout.downsample) * (image_shape[1] // layout.downsample),
+ layout.patch_size * layout.patch_size * layout.channels,
+ )
+ actual_shape = tuple(encoded.clean_state.components["latent"].shape)
+ if actual_shape != expected_shape:
+ raise ValueError(
+ "Bagel clean target state disagrees with the output-media token grid: "
+ f"expected {expected_shape}, received {actual_shape}"
+ )
+
+
+def _transform_target_images(
+ adapter: Any,
+ media_batch: DecodedMediaBatch,
+) -> tuple[list[torch.Tensor], Tuple[int, int]]:
+ transformed_images = []
+ image_shapes = []
+ for sample_index, candidate in enumerate(media_batch):
+ if len(candidate) != 1:
+ raise ValueError(
+ "Bagel output codec expected one image per sample, "
+ f"received {len(candidate)} for sample {sample_index}"
+ )
+ image = candidate[0].payload
+ if not isinstance(image, Image.Image):
+ raise TypeError(
+ "Bagel output codec expected decoded PIL.Image targets, "
+ f"received {type(image).__name__} for sample {sample_index}"
+ )
+ transformed = adapter.vae_transform(pil_img2rgb(image))
+ if not isinstance(transformed, torch.Tensor):
+ raise TypeError(
+ "Bagel vae_transform expected torch.Tensor output, "
+ f"received {type(transformed).__name__} for sample {sample_index}"
+ )
+ if transformed.ndim != 3 or transformed.shape[0] != 3:
+ raise ValueError(
+ "Bagel vae_transform expected CHW RGB output, "
+ f"received shape {tuple(transformed.shape)} for sample {sample_index}"
+ )
+ if not transformed.is_floating_point():
+ raise TypeError(
+ "Bagel vae_transform expected floating output, "
+ f"received dtype {transformed.dtype} for sample {sample_index}"
+ )
+ if transformed.shape[-2] <= 0 or transformed.shape[-1] <= 0:
+ raise ValueError(
+ "Bagel vae_transform produced non-positive target geometry "
+ f"{tuple(transformed.shape[-2:])} for sample {sample_index}"
+ )
+ image_shapes.append((transformed.shape[-2], transformed.shape[-1]))
+ transformed_images.append(transformed)
+
+ image_shape = image_shapes[0]
+ if any(shape != image_shape for shape in image_shapes[1:]):
+ raise ValueError(
+ "Bagel offline target batches require identical post-transform image "
+ f"geometry, received {tuple(image_shapes)}. Use batch size 1 or batch "
+ "targets by the geometry produced by Bagel's official vae_transform."
+ )
+ return transformed_images, image_shape
+
+
+def _resized_image_shape(
+ transform: Any,
+ image: Any,
+ sample_index: int,
+) -> Tuple[int, int]:
+ if not isinstance(image, Image.Image):
+ raise TypeError(
+ "Bagel output geometry expected decoded PIL.Image targets, "
+ f"received {type(image).__name__} for sample {sample_index}"
+ )
+ resize_transform = getattr(transform, "resize_transform", None)
+ if not callable(resize_transform):
+ raise TypeError("Bagel output geometry validation requires vae_transform.resize_transform")
+ resized = resize_transform(pil_img2rgb(image))
+ if isinstance(resized, Image.Image):
+ return resized.height, resized.width
+ if isinstance(resized, torch.Tensor) and resized.ndim >= 2:
+ return resized.shape[-2], resized.shape[-1]
+ raise TypeError(
+ "Bagel resize_transform expected PIL.Image or Tensor output, "
+ f"received {type(resized).__name__} for sample {sample_index}"
+ )
+
+
+def _module_dtype(module: nn.Module) -> torch.dtype:
+ dtype = getattr(module, "dtype", None)
+ if not isinstance(dtype, torch.dtype):
+ try:
+ dtype = next(module.parameters()).dtype
+ except (AttributeError, StopIteration) as exc:
+ raise TypeError(
+ "Bagel output codec could not resolve the custom VAE parameter dtype"
+ ) from exc
+ if not dtype.is_floating_point:
+ raise TypeError(
+ "Bagel output codec expected a floating custom VAE dtype, " f"received {dtype}"
+ )
+ return dtype
+
+
+def _positive_int(value: Any, name: str) -> int:
+ if not isinstance(value, Integral) or isinstance(value, bool):
+ raise TypeError(
+ f"Bagel output codec expected integer {name}, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ result = int(value)
+ if result <= 0:
+ raise ValueError(
+ f"Bagel output codec expected positive integer {name}, received {result!r}"
+ )
+ return result
+
+
+def _finite_real(value: Any, name: str) -> float:
+ if not isinstance(value, Real) or isinstance(value, bool):
+ raise TypeError(
+ f"Bagel output codec expected real {name}, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ result = float(value)
+ if not torch.isfinite(torch.tensor(result)):
+ raise ValueError(f"Bagel output codec expected finite {name}, received {result!r}")
+ return result
+
+
+__all__ = [
+ "BagelLatentLayout",
+ "BagelOutputStateCodec",
+ "BagelPosteriorMode",
+ "encode_bagel_vae_image",
+ "pack_bagel_latents",
+ "resolve_bagel_latent_layout",
+ "validate_bagel_encoded_output_geometry",
+]
diff --git a/src/flow_factory/models/bagel/bagel.py b/src/flow_factory/models/bagel/bagel.py
index 289a19667..478a482d9 100644
--- a/src/flow_factory/models/bagel/bagel.py
+++ b/src/flow_factory/models/bagel/bagel.py
@@ -53,6 +53,7 @@
import os
import random
from collections import defaultdict
+from collections.abc import Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
@@ -65,6 +66,12 @@
from PIL import Image
from tqdm import tqdm
+from ...contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaOrder,
+ NegativePromptPolicy,
+)
from ...hparams import Arguments
from ...samples import I2ISample, T2ISample
from ...scheduler import (
@@ -88,7 +95,13 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
+from ..pipeline_contracts import image_output_contract
from ..runtime import ComponentRuntime, PseudoPipelineRuntime
+from ._output import (
+ BagelOutputStateCodec,
+ validate_bagel_encoded_output_geometry,
+)
# Bagel's LLM attention (qwen2_navit) hard-requires flash-attn's varlen kernel,
# imported transitively by the `.modeling` imports below. Fail fast here with
@@ -185,6 +198,14 @@ class BagelAdapter(BaseAdapter):
# so ragged multi-reference batches serialize; they read back as PIL and are
# re-normalized by ``_normalize_condition_images``.
python_format_columns: ClassVar[frozenset[str]] = frozenset({"condition_images"})
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_image_min_count=0,
+ input_image_max_count=None,
+ input_order=InputMediaOrder.WITHIN_TYPE,
+ geometry_source=GeometrySource.OUTPUT_MEDIA,
+ batch_capability=BatchCapability.UNIFORM,
+ )
# Bagel is a mixture-of-transformer-experts model: the generation path uses
# *_moe_gen experts while the understanding/ViT path is unused during RL
@@ -267,6 +288,30 @@ def build_component_runtime(self) -> ComponentRuntime:
alias_routes={"transformer": ("bagel", ("language_model",))},
)
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Declare Bagel's on-the-fly stochastic target-image codec.
+
+ Returns:
+ Codec requiring the logical Bagel and VAE components at encode time.
+ """
+ return BagelOutputStateCodec(self)
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Verify target geometry against Bagel's output-media transform.
+
+ Args:
+ media_batch: Decoded target images validated by the shared boundary.
+ condition: Input condition paired with the targets.
+ encoded: Codec result after generic validation.
+ """
+ del condition
+ validate_bagel_encoded_output_geometry(self, media_batch, encoded)
+
def load_scheduler(self) -> FlowMatchEulerDiscreteSDEScheduler:
"""
Create a FlowMatchEulerDiscreteSDEScheduler for Bagel.
diff --git a/src/flow_factory/models/bagel/modeling/autoencoder.py b/src/flow_factory/models/bagel/modeling/autoencoder.py
index 0b4ad8805..20cf3a5b7 100644
--- a/src/flow_factory/models/bagel/modeling/autoencoder.py
+++ b/src/flow_factory/models/bagel/modeling/autoencoder.py
@@ -10,6 +10,7 @@
# This modified file is released under the same license.
from dataclasses import dataclass
+from typing import Optional
import torch
from einops import rearrange
@@ -280,13 +281,37 @@ def __init__(self, sample: bool = True, chunk_dim: int = 1):
self.sample = sample
self.chunk_dim = chunk_dim
- def forward(self, z: Tensor) -> Tensor:
+ def select(
+ self,
+ z: Tensor,
+ *,
+ sample: bool,
+ generator: Optional[torch.Generator] = None,
+ ) -> Tensor:
+ """Select a posterior sample or mean without changing global policy.
+
+ Args:
+ z: Encoder moments concatenating mean and log variance.
+ sample: Whether to draw from the posterior instead of using its mean.
+ generator: Optional generator used only for posterior sampling.
+
+ Returns:
+ Selected latent tensor.
+ """
mean, logvar = torch.chunk(z, 2, dim=self.chunk_dim)
- if self.sample:
+ if sample:
std = torch.exp(0.5 * logvar)
- return mean + std * torch.randn_like(mean)
- else:
- return mean
+ noise = torch.randn(
+ mean.shape,
+ generator=generator,
+ device=mean.device,
+ dtype=mean.dtype,
+ )
+ return mean + std * noise
+ return mean
+
+ def forward(self, z: Tensor) -> Tensor:
+ return self.select(z, sample=self.sample)
class AutoEncoder(nn.Module):
@@ -316,8 +341,18 @@ def __init__(self, params: AutoEncoderParams):
def encode(self, x: Tensor) -> Tensor:
z = self.reg(self.encoder(x))
- z = self.scale_factor * (z - self.shift_factor)
- return z
+ return self.normalize_latents(z)
+
+ def normalize_latents(self, z: Tensor) -> Tensor:
+ """Apply the latent shift and scale shared by every encoder role.
+
+ Args:
+ z: Unnormalized latent tensor selected from the posterior.
+
+ Returns:
+ Latents in Bagel's denoising space.
+ """
+ return self.scale_factor * (z - self.shift_factor)
def decode(self, z: Tensor) -> Tensor:
z = z / self.scale_factor + self.shift_factor
diff --git a/tests/models/test_all_model_loaders.py b/tests/models/test_all_model_loaders.py
index 5154ec67b..729fdbdeb 100644
--- a/tests/models/test_all_model_loaders.py
+++ b/tests/models/test_all_model_loaders.py
@@ -138,7 +138,10 @@ def test_bagel_adapter_imports_with_its_optional_kernel_contract(
flash_attn = types.ModuleType("flash_attn")
flash_attn.__spec__ = importlib.machinery.ModuleSpec("flash_attn", loader=None)
flash_attn.flash_attn_varlen_func = lambda *args, **kwargs: None
+ cv2 = types.ModuleType("cv2")
+ cv2.__spec__ = importlib.machinery.ModuleSpec("cv2", loader=None)
monkeypatch.setitem(sys.modules, "flash_attn", flash_attn)
+ monkeypatch.setitem(sys.modules, "cv2", cv2)
monkeypatch.setattr(import_utils, "is_flash_attn_available", lambda *args: True)
monkeypatch.setattr(import_utils, "get_flash_attn_version", lambda: "test")
diff --git a/tests/models/test_bagel_output_codec.py b/tests/models/test_bagel_output_codec.py
new file mode 100644
index 000000000..ae6c175fb
--- /dev/null
+++ b/tests/models/test_bagel_output_codec.py
@@ -0,0 +1,383 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Fake-component coverage for Bagel's offline image output codec."""
+
+from __future__ import annotations
+
+import importlib
+import importlib.machinery
+import sys
+import types
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Any, Optional
+
+import pytest
+import torch
+import torch.nn as nn
+from PIL import Image
+
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ MediaType,
+ NegativePromptPolicy,
+)
+from flow_factory.models.bagel._output import encode_bagel_vae_image
+from flow_factory.models.output_state import GeometrySignature, MediaGeometrySignature
+
+
+@dataclass(frozen=True)
+class _DecodedMedia:
+ type: str
+ payload: Any
+ fps: Optional[float] = None
+ sample_rate: Optional[int] = None
+
+
+class _FakeBagelTransform:
+ def __init__(self, output_shapes: dict[tuple[int, int], tuple[int, int]]) -> None:
+ self.output_shapes = output_shapes
+ self.calls: list[tuple[str, tuple[int, int], bool]] = []
+
+ def _shape(self, image: Image.Image) -> tuple[int, int]:
+ return self.output_shapes[image.size]
+
+ def resize_transform(self, image: Image.Image) -> Image.Image:
+ height, width = self._shape(image)
+ self.calls.append(("resize", image.size, torch.is_grad_enabled()))
+ return image.resize((width, height))
+
+ def __call__(self, image: Image.Image) -> torch.Tensor:
+ resized = self.resize_transform(image)
+ self.calls.append(("tensor", image.size, torch.is_grad_enabled()))
+ height, width = resized.height, resized.width
+ return torch.arange(3 * height * width, dtype=torch.float32).reshape(
+ 3,
+ height,
+ width,
+ )
+
+
+class _FakeEncoder(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.inputs: list[torch.Tensor] = []
+ self.outputs: list[torch.Tensor] = []
+
+ def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
+ self.inputs.append(pixel_values)
+ mean = torch.cat(
+ (
+ pixel_values[:, :1, ::8, ::8] / 64,
+ pixel_values[:, 1:2, ::8, ::8] / 32,
+ ),
+ dim=1,
+ )
+ logvar = torch.zeros_like(mean)
+ moments = torch.cat((mean, logvar), dim=1)
+ self.outputs.append(moments)
+ return moments
+
+
+class _FakeReg:
+ def __init__(self) -> None:
+ self.sample = False
+ self.chunk_dim = 1
+ self.selections: list[tuple[bool, Optional[torch.Generator]]] = []
+
+ def select(
+ self,
+ moments: torch.Tensor,
+ *,
+ sample: bool,
+ generator: Optional[torch.Generator] = None,
+ ) -> torch.Tensor:
+ self.selections.append((sample, generator))
+ mean, logvar = torch.chunk(moments, 2, dim=self.chunk_dim)
+ if not sample:
+ return mean
+ noise = torch.randn(
+ mean.shape,
+ generator=generator,
+ device=mean.device,
+ dtype=mean.dtype,
+ )
+ return mean + torch.exp(0.5 * logvar) * noise
+
+
+class _FakeBagelVAE(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.weight = nn.Parameter(torch.zeros((), dtype=torch.bfloat16))
+ self.encoder = _FakeEncoder()
+ self.reg = _FakeReg()
+ self.scale_factor = 0.5
+ self.shift_factor = 0.25
+ self.encode_calls = 0
+
+ def encode(self, pixel_values: torch.Tensor) -> torch.Tensor:
+ self.encode_calls += 1
+ moments = self.encoder(pixel_values)
+ latents = self.reg.select(moments, sample=self.reg.sample)
+ return self.normalize_latents(latents)
+
+ def normalize_latents(self, latents: torch.Tensor) -> torch.Tensor:
+ return self.scale_factor * (latents - self.shift_factor)
+
+ def decode(self, latents: torch.Tensor) -> torch.Tensor:
+ return latents / self.scale_factor + self.shift_factor
+
+
+class _Runtime:
+ def __init__(self, bagel: Any, vae: nn.Module) -> None:
+ self.bagel = bagel
+ self.vae = vae
+
+ def get_component(self, name: str) -> Any:
+ return {"bagel": self.bagel, "vae": self.vae}[name]
+
+
+class _DeclarationRuntime:
+ materialized_component_names = ()
+ override_components: dict[str, Any] = {}
+ declared_component_names = ("bagel", "vae")
+
+
+def _load_bagel_adapter(monkeypatch: pytest.MonkeyPatch) -> type:
+ import flow_factory.utils.imports as import_utils
+
+ flash_attn = types.ModuleType("flash_attn")
+ flash_attn.__spec__ = importlib.machinery.ModuleSpec("flash_attn", loader=None)
+ flash_attn.flash_attn_varlen_func = lambda *args, **kwargs: None
+ cv2 = types.ModuleType("cv2")
+ cv2.__spec__ = importlib.machinery.ModuleSpec("cv2", loader=None)
+ monkeypatch.setitem(sys.modules, "flash_attn", flash_attn)
+ monkeypatch.setitem(sys.modules, "cv2", cv2)
+ monkeypatch.setattr(import_utils, "is_flash_attn_available", lambda *args: True)
+ monkeypatch.setattr(import_utils, "get_flash_attn_version", lambda: "test")
+ return importlib.import_module("flow_factory.models.bagel.bagel").BagelAdapter
+
+
+def _install_adapter(
+ adapter_cls: type,
+ *,
+ transform: _FakeBagelTransform,
+ vae: _FakeBagelVAE,
+ patch_size: int = 2,
+) -> Any:
+ adapter = object.__new__(adapter_cls)
+ bagel = SimpleNamespace(
+ latent_patch_size=patch_size,
+ latent_channel=2,
+ latent_downsample=16,
+ )
+ adapter.training_args = SimpleNamespace(latent_storage_dtype=None)
+ adapter.accelerator = SimpleNamespace(device=torch.device("cpu"))
+ adapter.vae_transform = transform
+ adapter.pipeline = SimpleNamespace(bagel=bagel, vae=vae)
+ adapter.component_runtime = _Runtime(bagel, vae)
+ adapter._output_state_encoding_modules = ("bagel", "vae")
+ adapter._output_state_codec = adapter.build_output_state_codec()
+ return adapter
+
+
+def _media_batch(*sizes: tuple[int, int]) -> tuple[tuple[_DecodedMedia, ...], ...]:
+ return tuple((_DecodedMedia(type="image", payload=Image.new("RGB", size)),) for size in sizes)
+
+
+def _manual_patchify(latents: torch.Tensor) -> torch.Tensor:
+ packed_samples = []
+ for sample in latents:
+ tokens = []
+ for latent_h in range(sample.shape[-2] // 2):
+ for latent_w in range(sample.shape[-1] // 2):
+ token = []
+ for patch_h in range(2):
+ for patch_w in range(2):
+ for channel in range(sample.shape[0]):
+ token.append(
+ sample[
+ channel,
+ latent_h * 2 + patch_h,
+ latent_w * 2 + patch_w,
+ ]
+ )
+ tokens.append(torch.stack(token))
+ packed_samples.append(torch.stack(tokens))
+ return torch.stack(packed_samples)
+
+
+def test_bagel_pipeline_contract_covers_t2i_and_ordered_multi_image_i2i(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_cls = _load_bagel_adapter(monkeypatch)
+ adapter_cls.validate_offline_output_capability()
+ contract = adapter_cls.pipeline_io_contract
+
+ assert contract.negative_prompt is NegativePromptPolicy.UNSUPPORTED
+ assert contract.geometry_source is GeometrySource.OUTPUT_MEDIA
+ assert contract.batch_capability is BatchCapability.UNIFORM
+ assert contract.input_media.binding is InputMediaBinding.GROUPED_BY_TYPE
+ assert contract.input_media.order is InputMediaOrder.WITHIN_TYPE
+ assert len(contract.input_media.rules) == 1
+ assert contract.input_media.rules[0].format.type is MediaType.IMAGE
+ assert contract.input_media.rules[0].min_count == 0
+ assert contract.input_media.rules[0].max_count is None
+ assert tuple(item.type for item in contract.output_media.items) == (MediaType.IMAGE,)
+
+
+def test_bagel_codec_declaration_is_logical_and_does_not_touch_components(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_cls = _load_bagel_adapter(monkeypatch)
+ adapter = object.__new__(adapter_cls)
+ adapter.component_runtime = _DeclarationRuntime()
+
+ codec = adapter._build_output_state_codec_declaration()
+ adapter._output_state_codec = codec
+
+ assert codec.required_components == ("bagel", "vae")
+ assert adapter._validate_output_state_codec_lifecycle() == ("bagel", "vae")
+ assert adapter.component_runtime.materialized_component_names == ()
+ assert adapter.component_runtime.override_components == {}
+
+
+def test_bagel_target_samples_encoder_moments_without_mutating_condition_policy(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_cls = _load_bagel_adapter(monkeypatch)
+ transform = _FakeBagelTransform({(7, 9): (16, 32), (11, 13): (16, 32)})
+ vae = _FakeBagelVAE()
+ adapter = _install_adapter(adapter_cls, transform=transform, vae=vae)
+ generator = torch.Generator().manual_seed(17)
+
+ encoded = adapter.encode_output_state(
+ _media_batch((7, 9), (11, 13)),
+ {"prompt": ["first", "second"]},
+ generator,
+ )
+
+ assert vae.reg.sample is False
+ assert vae.encode_calls == 0
+ assert vae.reg.selections == [(True, generator)]
+ assert len(vae.encoder.inputs) == 1
+ assert vae.encoder.inputs[0].dtype is torch.bfloat16
+ assert all(not grad_enabled for kind, _, grad_enabled in transform.calls if kind == "tensor")
+
+ mean, logvar = torch.chunk(vae.encoder.outputs[0], 2, dim=1)
+ expected_noise = torch.randn(
+ mean.shape,
+ generator=torch.Generator().manual_seed(17),
+ dtype=mean.dtype,
+ )
+ sampled = mean + torch.exp(0.5 * logvar) * expected_noise
+ normalized = vae.scale_factor * (sampled - vae.shift_factor)
+ assert torch.equal(encoded.clean_state.components["latent"], _manual_patchify(normalized))
+ assert encoded.clean_state.components["latent"].shape == (2, 2, 8)
+ assert dict(encoded.forward_context) == {"image_shape": (16, 32)}
+ assert dict(encoded.decode_context) == {"image_shape": (16, 32)}
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=16,
+ width=32,
+ ),
+ )
+ )
+ assert encoded.geometry_signatures == (signature, signature)
+
+
+def test_bagel_role_neutral_primitive_keeps_argmax_and_sample_explicit() -> None:
+ vae = _FakeBagelVAE()
+ pixels = torch.arange(3 * 16 * 16, dtype=torch.float32).reshape(1, 3, 16, 16)
+ generator = torch.Generator().manual_seed(23)
+
+ argmax = encode_bagel_vae_image(vae, pixels, posterior_mode="argmax")
+ sampled = encode_bagel_vae_image(
+ vae,
+ pixels,
+ posterior_mode="sample",
+ generator=generator,
+ )
+
+ mean, logvar = torch.chunk(vae.encoder.outputs[0], 2, dim=1)
+ expected_noise = torch.randn(
+ mean.shape,
+ generator=torch.Generator().manual_seed(23),
+ )
+ assert torch.equal(argmax, vae.scale_factor * (mean - vae.shift_factor))
+ assert torch.equal(
+ sampled,
+ vae.scale_factor * (mean + torch.exp(0.5 * logvar) * expected_noise - vae.shift_factor),
+ )
+ assert vae.reg.sample is False
+
+
+def test_bagel_custom_diagonal_gaussian_preserves_global_condition_policy(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _load_bagel_adapter(monkeypatch)
+ autoencoder = importlib.import_module("flow_factory.models.bagel.modeling.autoencoder")
+ reg = autoencoder.DiagonalGaussian(sample=False)
+ mean = torch.arange(8, dtype=torch.float32).reshape(1, 2, 2, 2)
+ logvar = torch.zeros_like(mean)
+ moments = torch.cat((mean, logvar), dim=1)
+ generator = torch.Generator().manual_seed(31)
+
+ sampled = reg.select(moments, sample=True, generator=generator)
+ condition = reg(moments)
+ expected_noise = torch.randn(
+ mean.shape,
+ generator=torch.Generator().manual_seed(31),
+ )
+
+ assert torch.equal(sampled, mean + expected_noise)
+ assert torch.equal(condition, mean)
+ assert reg.sample is False
+
+
+def test_bagel_target_codec_rejects_ragged_post_transform_geometry_before_encode(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_cls = _load_bagel_adapter(monkeypatch)
+ transform = _FakeBagelTransform({(7, 9): (16, 32), (11, 13): (32, 16)})
+ vae = _FakeBagelVAE()
+ adapter = _install_adapter(adapter_cls, transform=transform, vae=vae)
+
+ with pytest.raises(ValueError, match="batch size 1 or batch targets by the geometry"):
+ adapter.encode_output_state(_media_batch((7, 9), (11, 13)), {})
+
+ assert vae.encoder.inputs == []
+
+
+def test_bagel_target_codec_requires_the_official_two_by_two_patch_layout(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_cls = _load_bagel_adapter(monkeypatch)
+ transform = _FakeBagelTransform({(7, 9): (16, 32)})
+ vae = _FakeBagelVAE()
+ adapter = _install_adapter(
+ adapter_cls,
+ transform=transform,
+ vae=vae,
+ patch_size=1,
+ )
+
+ with pytest.raises(ValueError, match="latent_patch_size=2"):
+ adapter.encode_output_state(_media_batch((7, 9)), {})
From e044dd9d62fb8e00da83c242cb9be9b200e0a0bb Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:40:14 +0800
Subject: [PATCH 15/76] feat(models): add Wan target video codec
---
src/flow_factory/models/wan/_output.py | 149 +++++++++++++++++++
src/flow_factory/models/wan/wan2_t2v.py | 178 +++++++++++++++++++++-
tests/models/test_wan_output_codec.py | 189 ++++++++++++++++++++++++
3 files changed, 513 insertions(+), 3 deletions(-)
create mode 100644 src/flow_factory/models/wan/_output.py
create mode 100644 tests/models/test_wan_output_codec.py
diff --git a/src/flow_factory/models/wan/_output.py b/src/flow_factory/models/wan/_output.py
new file mode 100644
index 000000000..887e61232
--- /dev/null
+++ b/src/flow_factory/models/wan/_output.py
@@ -0,0 +1,149 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""On-the-fly clean-video encoding for Wan text-to-video adapters."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any, ClassVar, Optional, Tuple
+
+import numpy as np
+import torch
+
+from ...contracts import MediaType
+from ...samples import LatentState
+from ..configured_image_output import retrieve_vae_latents
+from ..output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+
+
+@dataclass(frozen=True, slots=True)
+class WanVideoOutputCodec:
+ """Encode configured Wan target videos without retaining pixels or latents."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("vae",)
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Preprocess, VAE-sample, and normalize one video per sample."""
+ del condition
+ height, width, num_frames, frame_rate = self.adapter._configured_video_output_geometry()
+ videos = []
+ for sample_index, candidate in enumerate(media_batch):
+ if len(candidate) != 1:
+ raise ValueError(
+ "Wan output codec expected one video per sample, "
+ f"received {len(candidate)} for sample {sample_index}"
+ )
+ media = candidate[0]
+ payload = media.payload
+ if not isinstance(payload, np.ndarray):
+ raise TypeError(
+ "Wan output codec expected decoded NumPy video targets, "
+ f"received {type(payload).__name__} for sample {sample_index}"
+ )
+ videos.append(
+ self.adapter._resample_output_video(
+ payload,
+ source_fps=media.fps,
+ target_frames=num_frames,
+ target_fps=frame_rate,
+ )
+ )
+
+ pixel_values = self.adapter.pipeline.video_processor.preprocess_video(
+ videos,
+ height=height,
+ width=width,
+ )
+ if not isinstance(pixel_values, torch.Tensor):
+ raise TypeError(
+ "Wan video_processor.preprocess_video must return torch.Tensor, "
+ f"received {type(pixel_values).__name__}"
+ )
+ expected_shape = (len(videos), 3, num_frames, height, width)
+ if tuple(pixel_values.shape) != expected_shape:
+ raise ValueError(
+ "Wan video preprocessing changed configured output geometry: "
+ f"expected {expected_shape}, received {tuple(pixel_values.shape)}"
+ )
+
+ vae = self.adapter.vae
+ vae_dtype = getattr(vae, "dtype", None)
+ if not isinstance(vae_dtype, torch.dtype) or not vae_dtype.is_floating_point:
+ raise TypeError(
+ "Wan output codec expected VAE to expose a floating dtype, "
+ f"received {vae_dtype!r}"
+ )
+ pixel_values = pixel_values.to(device=self.adapter.device, dtype=vae_dtype)
+ encoded = vae.encode(pixel_values)
+ latents = retrieve_vae_latents(
+ encoded,
+ sample_mode="sample",
+ generator=generator,
+ source="Wan target video",
+ )
+ latents = self.adapter._normalize_output_video_latents(latents)
+
+ temporal_scale = self.adapter.pipeline.vae_scale_factor_temporal
+ spatial_scale = self.adapter.pipeline.vae_scale_factor_spatial
+ expected_latent_shape = (
+ len(videos),
+ getattr(vae.config, "z_dim", latents.shape[1]),
+ (num_frames - 1) // temporal_scale + 1,
+ height // spatial_scale,
+ width // spatial_scale,
+ )
+ if tuple(latents.shape) != expected_latent_shape:
+ raise ValueError(
+ "Wan VAE target latent geometry mismatch: "
+ f"expected {expected_latent_shape}, received {tuple(latents.shape)}"
+ )
+
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=height,
+ width=width,
+ frames=num_frames,
+ fps=frame_rate,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=LatentState({"latent": latents}),
+ forward_context={},
+ decode_context={
+ "height": height,
+ "width": width,
+ "num_frames": num_frames,
+ "frame_rate": frame_rate,
+ },
+ geometry_signatures=tuple(signature for _ in videos),
+ )
+
+
+__all__ = ["WanVideoOutputCodec"]
diff --git a/src/flow_factory/models/wan/wan2_t2v.py b/src/flow_factory/models/wan/wan2_t2v.py
index c898124b0..1c0db6aae 100644
--- a/src/flow_factory/models/wan/wan2_t2v.py
+++ b/src/flow_factory/models/wan/wan2_t2v.py
@@ -16,10 +16,12 @@
from __future__ import annotations
import logging
+import math
import os
from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from numbers import Real
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
@@ -28,6 +30,7 @@
from peft import PeftModel
from PIL import Image
+from ...contracts import GeometrySource, NegativePromptPolicy, RateRequirement
from ...hparams import *
from ...samples import T2VSample
from ...scheduler import UniPCMultistepSDEScheduler, UniPCMultistepSDESchedulerOutput
@@ -41,6 +44,9 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
+from ..pipeline_contracts import video_output_contract
+from ._output import WanVideoOutputCodec
logger = setup_logger(__name__)
@@ -62,6 +68,11 @@ class Wan2_T2V_Adapter(BaseAdapter):
"text_encoders": torch.bfloat16,
"vae": torch.float32,
}
+ pipeline_io_contract = video_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_fps=RateRequirement.REQUIRED,
+ geometry_source=GeometrySource.CONFIGURED,
+ )
def __init__(self, config: Arguments, accelerator: Accelerator):
super().__init__(config, accelerator)
@@ -238,11 +249,172 @@ def encode_prompt(
def encode_image(self, images: Union[Image.Image, torch.Tensor, List[torch.Tensor]]):
"""Not needed for Wan text-to-video models."""
- pass
+ return None
def encode_video(self, videos: Union[torch.Tensor, List[torch.Tensor]]):
"""Not needed for Wan text-to-video models."""
- pass
+ return None
+
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Declare the on-the-fly target-video codec without loading components."""
+ return WanVideoOutputCodec(self)
+
+ def _configured_video_output_geometry(self) -> Tuple[int, int, int, float]:
+ """Return configured Wan geometry after exact latent-grid validation."""
+ geometry = []
+ for name in ("height", "width", "num_frames"):
+ value = getattr(self.training_args, name, None)
+ if type(value) is not int or value <= 0:
+ raise ValueError(
+ f"Wan output geometry requires positive integer train.{name}, "
+ f"received {value!r}"
+ )
+ geometry.append(value)
+ frame_rate = getattr(self.training_args, "frame_rate", None)
+ if isinstance(frame_rate, bool) or not isinstance(frame_rate, Real):
+ raise TypeError(
+ "Wan output geometry requires finite positive train.frame_rate, "
+ f"received {type(frame_rate).__name__}: {frame_rate!r}"
+ )
+ frame_rate = float(frame_rate)
+ if not math.isfinite(frame_rate) or frame_rate <= 0:
+ raise ValueError(
+ "Wan output geometry requires finite positive train.frame_rate, "
+ f"received {frame_rate!r}"
+ )
+
+ height, width, num_frames = geometry
+ temporal_scale = self.pipeline.vae_scale_factor_temporal
+ spatial_scale = self.pipeline.vae_scale_factor_spatial
+ if (num_frames - 1) % temporal_scale:
+ raise ValueError(
+ "Wan output num_frames must satisfy "
+ f"(num_frames - 1) % {temporal_scale} == 0, received {num_frames}"
+ )
+ transformer = (
+ self.pipeline.transformer
+ if self.pipeline.transformer is not None
+ else self.pipeline.transformer_2
+ )
+ if transformer is None:
+ raise RuntimeError("Wan output geometry requires one materialized transformer")
+ patch_size = transformer.config.patch_size
+ height_multiple = spatial_scale * patch_size[1]
+ width_multiple = spatial_scale * patch_size[2]
+ if height % height_multiple or width % width_multiple:
+ raise ValueError(
+ "Wan output height/width must be divisible by transformer latent-grid "
+ f"multiples {(height_multiple, width_multiple)}, received {(height, width)}"
+ )
+ return height, width, num_frames, frame_rate
+
+ @staticmethod
+ def _resample_output_video(
+ video: np.ndarray,
+ *,
+ source_fps: Optional[float],
+ target_frames: int,
+ target_fps: float,
+ ) -> np.ndarray:
+ """Select deterministic nearest-time frames for configured target cadence."""
+ if video.dtype != np.uint8 or video.ndim != 4 or video.shape[-1] != 3:
+ raise ValueError(
+ "Wan decoded target video must be uint8 RGB shaped (F,H,W,3), "
+ f"received dtype={video.dtype}, shape={tuple(video.shape)}"
+ )
+ if video.shape[0] < 1:
+ raise ValueError("Wan decoded target video must contain at least one frame")
+ if isinstance(source_fps, bool) or not isinstance(source_fps, Real):
+ raise TypeError(
+ "Wan target video requires source fps metadata, "
+ f"received {type(source_fps).__name__}: {source_fps!r}"
+ )
+ source_fps = float(source_fps)
+ if not math.isfinite(source_fps) or source_fps <= 0:
+ raise ValueError(f"Wan target video requires positive finite fps, got {source_fps!r}")
+ indices = np.rint(
+ np.arange(target_frames, dtype=np.float64) * source_fps / target_fps
+ ).astype(np.int64)
+ if indices[-1] >= video.shape[0]:
+ required_duration = (target_frames - 1) / target_fps
+ available_duration = (video.shape[0] - 1) / source_fps
+ raise ValueError(
+ "Wan target video is too short for configured temporal geometry: "
+ f"requires {required_duration:.6f}s, has {available_duration:.6f}s"
+ )
+ return np.ascontiguousarray(video[indices])
+
+ def _normalize_output_video_latents(self, latents: torch.Tensor) -> torch.Tensor:
+ """Apply the exact inverse of Wan's existing decode normalization."""
+ if not isinstance(latents, torch.Tensor) or latents.ndim != 5:
+ raise ValueError(
+ "Wan VAE target latents must be rank-5 BCFHW, "
+ f"received {type(latents).__name__} with shape "
+ f"{getattr(latents, 'shape', None)}"
+ )
+ config = self.vae.config
+ z_dim = config.z_dim
+ if latents.shape[1] != z_dim:
+ raise ValueError(
+ f"Wan VAE target latent channels must equal z_dim={z_dim}, "
+ f"received {latents.shape[1]}"
+ )
+ latents_mean = torch.as_tensor(
+ config.latents_mean,
+ device=latents.device,
+ dtype=latents.dtype,
+ ).view(1, z_dim, 1, 1, 1)
+ inverse_std = (
+ torch.as_tensor(
+ config.latents_std,
+ device=latents.device,
+ dtype=latents.dtype,
+ )
+ .reciprocal()
+ .view(1, z_dim, 1, 1, 1)
+ )
+ return (latents - latents_mean) * inverse_std
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Require encoded signatures and decode metadata to match train geometry."""
+ del condition
+ height, width, num_frames, frame_rate = self._configured_video_output_geometry()
+ if len(encoded.geometry_signatures) != len(media_batch):
+ raise ValueError(
+ "Wan output codec must return one geometry signature per sample, "
+ f"received {len(encoded.geometry_signatures)} for {len(media_batch)}"
+ )
+ for sample_index, signature in enumerate(encoded.geometry_signatures):
+ geometry = signature.media[0]
+ received = (
+ geometry.height,
+ geometry.width,
+ geometry.frames,
+ geometry.fps,
+ )
+ expected = (height, width, num_frames, frame_rate)
+ if received != expected:
+ raise ValueError(
+ "Wan encoded output geometry disagrees with configured geometry for "
+ f"sample {sample_index}: expected {expected}, received {received}"
+ )
+ expected_context = {
+ "height": height,
+ "width": width,
+ "num_frames": num_frames,
+ "frame_rate": frame_rate,
+ }
+ for name, expected in expected_context.items():
+ if encoded.decode_context.get(name) != expected:
+ raise ValueError(
+ f"Wan decode_context {name!r} must equal {expected!r}, "
+ f"received {encoded.decode_context.get(name)!r}"
+ )
def decode_latents(
self, latents: torch.Tensor, output_type: Literal["pt", "pil", "np"] = "pil"
diff --git a/tests/models/test_wan_output_codec.py b/tests/models/test_wan_output_codec.py
new file mode 100644
index 000000000..ce2a0b1a3
--- /dev/null
+++ b/tests/models/test_wan_output_codec.py
@@ -0,0 +1,189 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+import pytest
+import torch
+
+from flow_factory.contracts import GeometrySource, MediaType, RateRequirement
+from flow_factory.data_utils.offline_dataset import DecodedMedia
+from flow_factory.models.wan._output import WanVideoOutputCodec
+from flow_factory.models.wan.wan2_t2v import Wan2_T2V_Adapter
+
+
+class _Posterior:
+ def __init__(self, latents: torch.Tensor) -> None:
+ self.latents = latents
+ self.generators: list[torch.Generator | None] = []
+
+ def sample(self, generator: torch.Generator | None = None) -> torch.Tensor:
+ self.generators.append(generator)
+ return self.latents
+
+
+class _VAE:
+ dtype = torch.float32
+
+ def __init__(self) -> None:
+ self.config = SimpleNamespace(
+ z_dim=3,
+ latents_mean=[1.0, 2.0, 3.0],
+ latents_std=[2.0, 4.0, 5.0],
+ )
+ raw_channels = torch.tensor([3.0, 6.0, 8.0]).view(1, 3, 1, 1, 1)
+ self.posterior = _Posterior(raw_channels.expand(1, 3, 2, 2, 2).clone())
+ self.encoded_pixels: list[torch.Tensor] = []
+
+ def encode(self, pixels: torch.Tensor) -> Any:
+ self.encoded_pixels.append(pixels)
+ return SimpleNamespace(latent_dist=self.posterior)
+
+
+class _VideoProcessor:
+ def __init__(self) -> None:
+ self.videos: list[list[np.ndarray]] = []
+
+ def preprocess_video(
+ self,
+ videos: list[np.ndarray],
+ *,
+ height: int,
+ width: int,
+ ) -> torch.Tensor:
+ self.videos.append(videos)
+ return torch.zeros(len(videos), 3, videos[0].shape[0], height, width)
+
+
+class _Adapter:
+ _configured_video_output_geometry = Wan2_T2V_Adapter._configured_video_output_geometry
+ _resample_output_video = staticmethod(Wan2_T2V_Adapter._resample_output_video)
+ _normalize_output_video_latents = Wan2_T2V_Adapter._normalize_output_video_latents
+
+ def __init__(self) -> None:
+ self.device = torch.device("cpu")
+ self.training_args = SimpleNamespace(
+ height=16,
+ width=16,
+ num_frames=5,
+ frame_rate=4.0,
+ )
+ self.vae = _VAE()
+ self.pipeline = SimpleNamespace(
+ vae_scale_factor_temporal=4,
+ vae_scale_factor_spatial=8,
+ transformer=SimpleNamespace(config=SimpleNamespace(patch_size=(1, 2, 2))),
+ transformer_2=None,
+ video_processor=_VideoProcessor(),
+ )
+
+
+def _media(video: np.ndarray, fps: float = 8.0):
+ return (
+ (
+ DecodedMedia(
+ type="video",
+ path="target.mp4",
+ payload=video,
+ fps=fps,
+ ),
+ ),
+ )
+
+
+def test_wan_t2v_declares_required_video_output_semantics() -> None:
+ contract = Wan2_T2V_Adapter.pipeline_io_contract
+
+ assert contract.geometry_source is GeometrySource.CONFIGURED
+ assert contract.output_media.items[0].type is MediaType.VIDEO
+ assert contract.output_media.items[0].fps is RateRequirement.REQUIRED
+ Wan2_T2V_Adapter.validate_offline_output_capability()
+
+ adapter = object.__new__(Wan2_T2V_Adapter)
+ codec = adapter.build_output_state_codec()
+ assert isinstance(codec, WanVideoOutputCodec)
+ assert codec.required_components == ("vae",)
+
+
+def test_wan_codec_resamples_preprocesses_and_samples_target_latents() -> None:
+ adapter = _Adapter()
+ codec = WanVideoOutputCodec(adapter)
+ source = np.arange(9 * 4 * 4 * 3, dtype=np.uint8).reshape(9, 4, 4, 3)
+ generator = torch.Generator().manual_seed(7)
+
+ encoded = codec.encode_output_state(_media(source), {}, generator)
+
+ selected = adapter.pipeline.video_processor.videos[0][0]
+ np.testing.assert_array_equal(selected, source[[0, 2, 4, 6, 8]])
+ assert adapter.vae.posterior.generators == [generator]
+ torch.testing.assert_close(
+ encoded.clean_state.components["latent"],
+ torch.ones(1, 3, 2, 2, 2),
+ )
+ assert encoded.forward_context == {}
+ assert dict(encoded.decode_context) == {
+ "height": 16,
+ "width": 16,
+ "num_frames": 5,
+ "frame_rate": 4.0,
+ }
+ geometry = encoded.geometry_signatures[0].media[0]
+ assert (geometry.type, geometry.height, geometry.width, geometry.frames, geometry.fps) == (
+ MediaType.VIDEO,
+ 16,
+ 16,
+ 5,
+ 4.0,
+ )
+
+
+def test_wan_codec_rejects_insufficient_duration_and_invalid_latent_grid() -> None:
+ adapter = _Adapter()
+ codec = WanVideoOutputCodec(adapter)
+ short = np.zeros((8, 4, 4, 3), dtype=np.uint8)
+
+ with pytest.raises(ValueError, match="too short"):
+ codec.encode_output_state(_media(short), {})
+
+ adapter.training_args.num_frames = 6
+ with pytest.raises(ValueError, match="num_frames must satisfy"):
+ adapter._configured_video_output_geometry()
+
+
+def test_wan_geometry_validator_rejects_output_context_drift() -> None:
+ adapter = _Adapter()
+ encoded = WanVideoOutputCodec(adapter).encode_output_state(
+ _media(np.zeros((9, 4, 4, 3), dtype=np.uint8)),
+ {},
+ )
+
+ Wan2_T2V_Adapter._validate_encoded_output_geometry(
+ adapter, _media(np.zeros((9, 1, 1, 3), dtype=np.uint8)), {}, encoded
+ )
+
+ drifted = type(encoded)(
+ clean_state=encoded.clean_state,
+ forward_context=encoded.forward_context,
+ decode_context={**dict(encoded.decode_context), "frame_rate": 8.0},
+ geometry_signatures=encoded.geometry_signatures,
+ )
+ with pytest.raises(ValueError, match="decode_context 'frame_rate'"):
+ Wan2_T2V_Adapter._validate_encoded_output_geometry(
+ adapter,
+ _media(np.zeros((9, 1, 1, 3), dtype=np.uint8)),
+ {},
+ drifted,
+ )
From 4aa6716df16dc0f6de09cda1964c84bd64f0da7f Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:40:20 +0800
Subject: [PATCH 16/76] feat(models): add SenseNova pixel output codec
---
src/flow_factory/models/sensenova/_output.py | 98 ++++++++++++++
.../models/sensenova/sensenova.py | 61 ++++++++-
tests/models/test_sensenova_output_codec.py | 121 ++++++++++++++++++
3 files changed, 277 insertions(+), 3 deletions(-)
create mode 100644 src/flow_factory/models/sensenova/_output.py
create mode 100644 tests/models/test_sensenova_output_codec.py
diff --git a/src/flow_factory/models/sensenova/_output.py b/src/flow_factory/models/sensenova/_output.py
new file mode 100644
index 000000000..ca61627c7
--- /dev/null
+++ b/src/flow_factory/models/sensenova/_output.py
@@ -0,0 +1,98 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Pixel-space output-state codec for SenseNova-U1."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any, ClassVar, Optional, Tuple
+
+import numpy as np
+import torch
+from PIL import Image
+
+from ...contracts import MediaType
+from ...samples import LatentState
+from ..output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+
+
+@dataclass(frozen=True, slots=True)
+class SenseNovaPixelOutputCodec:
+ """Encode configured target images directly as normalized pixel states."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("transformer",)
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Resize decoded RGB targets and map ``[0, 255]`` to ``[-1, 1]``."""
+ del condition, generator
+ height, width = self.adapter._configured_output_image_geometry()
+ arrays = []
+ for sample_index, candidate in enumerate(media_batch):
+ if len(candidate) != 1:
+ raise ValueError(
+ "SenseNova output codec expected one image per sample, "
+ f"received {len(candidate)} for sample {sample_index}"
+ )
+ payload = candidate[0].payload
+ if not isinstance(payload, Image.Image):
+ raise TypeError(
+ "SenseNova output codec expected decoded PIL.Image targets, "
+ f"received {type(payload).__name__} for sample {sample_index}"
+ )
+ resized = payload.convert("RGB").resize(
+ (width, height),
+ resample=Image.Resampling.BICUBIC,
+ )
+ arrays.append(np.asarray(resized, dtype=np.float32))
+
+ pixels = torch.from_numpy(np.stack(arrays, axis=0)).permute(0, 3, 1, 2)
+ pixels = pixels.div(127.5).sub(1.0)
+ model_dtype = getattr(self.adapter.transformer, "dtype", None)
+ if model_dtype not in (torch.float16, torch.bfloat16, torch.float32):
+ raise TypeError(
+ "SenseNova output codec expected transformer dtype in "
+ f"(float16, bfloat16, float32), received {model_dtype!r}"
+ )
+ pixels = pixels.to(device=self.adapter.device, dtype=model_dtype)
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.IMAGE,
+ height=height,
+ width=width,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=LatentState({"latent": pixels}),
+ forward_context={},
+ decode_context={"height": height, "width": width},
+ geometry_signatures=tuple(signature for _ in arrays),
+ )
+
+
+__all__ = ["SenseNovaPixelOutputCodec"]
diff --git a/src/flow_factory/models/sensenova/sensenova.py b/src/flow_factory/models/sensenova/sensenova.py
index 4c07e7dd4..321ffdff5 100644
--- a/src/flow_factory/models/sensenova/sensenova.py
+++ b/src/flow_factory/models/sensenova/sensenova.py
@@ -18,14 +18,18 @@
import math
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
-from PIL import Image
-
from diffusers.utils.torch_utils import randn_tensor
+from PIL import Image
+from ...contracts import (
+ GeometrySource,
+ InputMediaOrder,
+ NegativePromptPolicy,
+)
from ...samples import I2ISample, T2ISample
from ...scheduler import (
FlowMatchEulerDiscreteSDEScheduler,
@@ -38,7 +42,10 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
+from ..pipeline_contracts import image_output_contract
from ..runtime import ComponentRuntime, PseudoPipelineRuntime
+from ._output import SenseNovaPixelOutputCodec
from .modeling.neo_unify.modeling_neo_chat import (
SYSTEM_MESSAGE_FOR_GEN,
clear_flash_kv_cache,
@@ -91,6 +98,13 @@ class SenseNovaAdapter(BaseAdapter):
python_format_columns: ClassVar[frozenset[str]] = frozenset({"condition_images"})
ddp_find_unused_parameters = True
flow_velocity_direction: ClassVar[Literal["noise", "data"]] = "noise"
+ pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_image_min_count=0,
+ input_image_max_count=None,
+ input_order=InputMediaOrder.WITHIN_TYPE,
+ geometry_source=GeometrySource.CONFIGURED,
+ )
def load_pipeline(self) -> SenseNovaPseudoPipeline:
"""Load the custom Transformers checkpoint and tokenizer."""
@@ -177,6 +191,47 @@ def encode_image(
def encode_video(self, videos: Any, **kwargs: Any) -> None:
"""SenseNova-U1 has no video input in this adapter."""
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Declare direct pixel-state target encoding without model materialization."""
+ return SenseNovaPixelOutputCodec(self)
+
+ def _configured_output_image_geometry(self) -> Tuple[int, int]:
+ """Return configured H/W after validating the official patch merge grid."""
+ return self._image_shape(
+ getattr(self.training_args, "height", None),
+ getattr(self.training_args, "width", None),
+ None,
+ )
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Require target signatures and decode metadata to match configured H/W."""
+ del condition
+ height, width = self._configured_output_image_geometry()
+ if len(encoded.geometry_signatures) != len(media_batch):
+ raise ValueError(
+ "SenseNova output codec must return one geometry signature per sample, "
+ f"received {len(encoded.geometry_signatures)} for {len(media_batch)}"
+ )
+ for sample_index, signature in enumerate(encoded.geometry_signatures):
+ geometry = signature.media[0]
+ received = (geometry.height, geometry.width)
+ if received != (height, width):
+ raise ValueError(
+ "SenseNova encoded output geometry disagrees with configured H/W for "
+ f"sample {sample_index}: expected {(height, width)}, received {received}"
+ )
+ for name, value in (("height", height), ("width", width)):
+ if encoded.decode_context.get(name) != value:
+ raise ValueError(
+ f"SenseNova decode_context {name!r} must equal {value}, "
+ f"received {encoded.decode_context.get(name)!r}"
+ )
+
def decode_latents(
self,
latents: torch.Tensor,
diff --git a/tests/models/test_sensenova_output_codec.py b/tests/models/test_sensenova_output_codec.py
new file mode 100644
index 000000000..b35c67a02
--- /dev/null
+++ b/tests/models/test_sensenova_output_codec.py
@@ -0,0 +1,121 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+from PIL import Image
+
+from flow_factory.contracts import (
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ MediaType,
+ NegativePromptPolicy,
+)
+from flow_factory.data_utils.offline_dataset import DecodedMedia
+from flow_factory.models.sensenova._output import SenseNovaPixelOutputCodec
+from flow_factory.models.sensenova.sensenova import SenseNovaAdapter
+
+
+class _Adapter:
+ _image_shape = SenseNovaAdapter._image_shape
+ _configured_output_image_geometry = SenseNovaAdapter._configured_output_image_geometry
+
+ def __init__(self) -> None:
+ self.device = torch.device("cpu")
+ self.transformer = SimpleNamespace(dtype=torch.float32)
+ self.training_args = SimpleNamespace(height=8, width=16)
+ self.model = SimpleNamespace(patch_size=4, downsample_ratio=0.5)
+
+ def _base_model(self):
+ return self.model
+
+
+def _media(image: Image.Image):
+ return (
+ (
+ DecodedMedia(
+ type="image",
+ path="target.png",
+ payload=image,
+ ),
+ ),
+ )
+
+
+def test_sensenova_declares_ordered_images_and_pixel_output() -> None:
+ contract = SenseNovaAdapter.pipeline_io_contract
+
+ assert SenseNovaAdapter.supports_ordered_references is False
+ assert contract.input_media.binding is InputMediaBinding.GROUPED_BY_TYPE
+ assert contract.input_media.order is InputMediaOrder.WITHIN_TYPE
+ assert contract.input_media.rules[0].min_count == 0
+ assert contract.input_media.rules[0].max_count is None
+ assert contract.negative_prompt is NegativePromptPolicy.UNSUPPORTED
+ assert contract.geometry_source is GeometrySource.CONFIGURED
+ assert contract.output_media.items[0].type is MediaType.IMAGE
+ SenseNovaAdapter.validate_offline_output_capability()
+
+ codec = SenseNovaAdapter.build_output_state_codec(object.__new__(SenseNovaAdapter))
+ assert isinstance(codec, SenseNovaPixelOutputCodec)
+ assert codec.required_components == ("transformer",)
+
+
+def test_sensenova_codec_maps_configured_rgb_targets_to_pixel_state() -> None:
+ adapter = _Adapter()
+ target = Image.new("RGB", (3, 5), color=(255, 0, 0))
+
+ encoded = SenseNovaPixelOutputCodec(adapter).encode_output_state(
+ _media(target),
+ {"prompt": ["red"]},
+ torch.Generator().manual_seed(3),
+ )
+
+ pixels = encoded.clean_state.components["latent"]
+ assert pixels.shape == (1, 3, 8, 16)
+ torch.testing.assert_close(pixels[:, 0], torch.ones(1, 8, 16))
+ torch.testing.assert_close(pixels[:, 1:], -torch.ones(1, 2, 8, 16))
+ assert pixels.dtype is torch.float32
+ assert encoded.forward_context == {}
+ assert dict(encoded.decode_context) == {"height": 8, "width": 16}
+ geometry = encoded.geometry_signatures[0].media[0]
+ assert (geometry.type, geometry.height, geometry.width) == (MediaType.IMAGE, 8, 16)
+
+
+def test_sensenova_configured_geometry_uses_model_patch_merge_factor() -> None:
+ adapter = _Adapter()
+ assert adapter._configured_output_image_geometry() == (8, 16)
+
+ adapter.training_args.width = 12
+ with pytest.raises(ValueError, match="patch merge factor 8"):
+ adapter._configured_output_image_geometry()
+
+
+def test_sensenova_geometry_validator_rejects_decode_context_drift() -> None:
+ adapter = _Adapter()
+ media = _media(Image.new("RGB", (8, 16)))
+ encoded = SenseNovaPixelOutputCodec(adapter).encode_output_state(media, {})
+
+ SenseNovaAdapter._validate_encoded_output_geometry(adapter, media, {}, encoded)
+
+ drifted = type(encoded)(
+ clean_state=encoded.clean_state,
+ forward_context=encoded.forward_context,
+ decode_context={"height": 16, "width": 16},
+ geometry_signatures=encoded.geometry_signatures,
+ )
+ with pytest.raises(ValueError, match="decode_context 'height'"):
+ SenseNovaAdapter._validate_encoded_output_geometry(adapter, media, {}, drifted)
From 91cf8567dec305e8ae1e1f5440f2201e99ba3993 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:40:41 +0800
Subject: [PATCH 17/76] feat(models): declare offline media blockers
---
src/flow_factory/models/ltx2/ltx2_i2av.py | 6 +++
src/flow_factory/models/ltx2/ltx2_t2av.py | 6 +++
.../models/minimax_h3/adapters.py | 21 ++++++---
src/flow_factory/models/wan/wan2_i2v.py | 5 +++
.../test_offline_output_capability_matrix.py | 44 +++++++++++++++++++
5 files changed, 77 insertions(+), 5 deletions(-)
create mode 100644 tests/models/test_offline_output_capability_matrix.py
diff --git a/src/flow_factory/models/ltx2/ltx2_i2av.py b/src/flow_factory/models/ltx2/ltx2_i2av.py
index be614a54e..9fa9261df 100644
--- a/src/flow_factory/models/ltx2/ltx2_i2av.py
+++ b/src/flow_factory/models/ltx2/ltx2_i2av.py
@@ -177,6 +177,12 @@ class LTX2_I2AV_Adapter(BaseAdapter):
Shared logic with LTX2_T2AV_Adapter is handled via code duplication.
"""
+ output_state_codec_unavailable_reason = (
+ "LTX2 I2AV offline targets require paired video/audio decoding, exact duration "
+ "alignment, and an active mask for the pinned first-frame condition; those lossless "
+ "audiovisual output semantics are not yet implemented"
+ )
+
supports_diffusers_cache = True
trajectory_component_order: ClassVar[Tuple[str, ...]] = LTX2_COMPONENT_ORDER
diff --git a/src/flow_factory/models/ltx2/ltx2_t2av.py b/src/flow_factory/models/ltx2/ltx2_t2av.py
index 600bd87a3..66751c7fa 100644
--- a/src/flow_factory/models/ltx2/ltx2_t2av.py
+++ b/src/flow_factory/models/ltx2/ltx2_t2av.py
@@ -162,6 +162,12 @@ class LTX2_T2AV_Adapter(BaseAdapter):
log_probs is the joint policy log_prob that drives policy gradient training.
"""
+ output_state_codec_unavailable_reason = (
+ "LTX2 offline targets require paired video/audio decoding with detected sample rates, "
+ "official mel preprocessing, and exact duration alignment; the offline data plane "
+ "does not yet provide that lossless audiovisual boundary"
+ )
+
supports_diffusers_cache = True
trajectory_component_order: ClassVar[Tuple[str, ...]] = LTX2_COMPONENT_ORDER
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index a63daa549..ca2650573 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -154,11 +154,7 @@ def decode_latents(self, latents: Any, **kwargs: Any) -> Any:
def empty_decoded_media(self, batch_size: int) -> Any:
"""Preserve H3's video/audio/sample-rate decode structure without decoding."""
- if (
- not isinstance(batch_size, int)
- or isinstance(batch_size, bool)
- or batch_size < 1
- ):
+ if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1:
raise ValueError(
f"MiniMax H3 expected positive int batch_size, received {batch_size!r}"
)
@@ -197,6 +193,11 @@ def forward(self, **kwargs: Any) -> MultiModalStepOutput:
class MiniMaxH3T2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 text-to-video-audio partition."""
+ output_state_codec_unavailable_reason = (
+ "MiniMax H3 offline targets require lossless audiovisual decoding/alignment, and the "
+ "official target-video posterior policy is not defined by the inference encoders"
+ )
+
workflow: ClassVar[str] = "t2va"
transformer_component_name: ClassVar[str] = "transformer"
preprocessing_modules: ClassVar[List[str]] = ["text_encoder", "tokenizer", "processor"]
@@ -211,6 +212,11 @@ class MiniMaxH3T2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
class MiniMaxH3FL2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 first/last-frame partition."""
+ output_state_codec_unavailable_reason = (
+ "MiniMax H3 offline targets require lossless audiovisual decoding/alignment, and the "
+ "official target-video posterior policy is not defined by the inference encoders"
+ )
+
workflow: ClassVar[str] = "fl2va"
transformer_component_name: ClassVar[str] = "transformer"
preprocessing_modules: ClassVar[List[str]] = [
@@ -231,6 +237,11 @@ class MiniMaxH3FL2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
class MiniMaxH3Ref2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 omni-reference partition."""
+ output_state_codec_unavailable_reason = (
+ "MiniMax H3 offline targets require lossless audiovisual decoding/alignment, and the "
+ "official target-video posterior policy is not defined by the inference encoders"
+ )
+
workflow: ClassVar[str] = "ref2va"
transformer_component_name: ClassVar[str] = "transformer_ref"
supports_ordered_references: ClassVar[bool] = True
diff --git a/src/flow_factory/models/wan/wan2_i2v.py b/src/flow_factory/models/wan/wan2_i2v.py
index f980de5ae..b437e79b0 100644
--- a/src/flow_factory/models/wan/wan2_i2v.py
+++ b/src/flow_factory/models/wan/wan2_i2v.py
@@ -81,6 +81,11 @@ def retrieve_latents(
class Wan2_I2V_Adapter(BaseAdapter):
+ output_state_codec_unavailable_reason = (
+ "Wan I2V target encoding must bind an output-geometry-dependent first-frame VAE "
+ "condition (and Wan 2.2 first-frame mask); the current condition cache retains only "
+ "CLIP image embeddings, so this target/condition binder is not yet implemented"
+ )
# Wan2.2 trains both transformer and transformer_2 but uses only one per
# timestep (boundary_ratio), so under DDP the other's trainable params get no
# gradient in a given step. Ignored under DeepSpeed/FSDP.
diff --git a/tests/models/test_offline_output_capability_matrix.py b/tests/models/test_offline_output_capability_matrix.py
new file mode 100644
index 000000000..00afa9fc4
--- /dev/null
+++ b/tests/models/test_offline_output_capability_matrix.py
@@ -0,0 +1,44 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest
+
+from flow_factory.models.ltx2.ltx2_i2av import LTX2_I2AV_Adapter
+from flow_factory.models.ltx2.ltx2_t2av import LTX2_T2AV_Adapter
+from flow_factory.models.minimax_h3.adapters import (
+ MiniMaxH3FL2VAAdapter,
+ MiniMaxH3Ref2VAAdapter,
+ MiniMaxH3T2VAAdapter,
+)
+from flow_factory.models.wan.wan2_i2v import Wan2_I2V_Adapter
+
+
+@pytest.mark.parametrize(
+ ("adapter_type", "reason_fragment"),
+ [
+ (Wan2_I2V_Adapter, "first-frame VAE condition"),
+ (LTX2_T2AV_Adapter, "paired video/audio decoding"),
+ (LTX2_I2AV_Adapter, "active mask"),
+ (MiniMaxH3T2VAAdapter, "target-video posterior policy"),
+ (MiniMaxH3FL2VAAdapter, "target-video posterior policy"),
+ (MiniMaxH3Ref2VAAdapter, "target-video posterior policy"),
+ ],
+)
+def test_unimplemented_offline_media_semantics_fail_before_model_loading(
+ adapter_type: type,
+ reason_fragment: str,
+) -> None:
+ """Expose actionable blockers instead of silently guessing target encoding."""
+ with pytest.raises(NotImplementedError, match=reason_fragment):
+ adapter_type.validate_offline_output_capability()
From b6f751c88c23c44623576279e51bbeb0f6de1272 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 17:42:12 +0800
Subject: [PATCH 18/76] fix(data): preflight offline batch capability
---
.../data_utils/offline_train_data.py | 9 ++++++
tests/data_utils/test_offline_train_data.py | 30 +++++++++++++++++++
2 files changed, 39 insertions(+)
diff --git a/src/flow_factory/data_utils/offline_train_data.py b/src/flow_factory/data_utils/offline_train_data.py
index 8391c8eca..637ff84ef 100644
--- a/src/flow_factory/data_utils/offline_train_data.py
+++ b/src/flow_factory/data_utils/offline_train_data.py
@@ -24,6 +24,7 @@
from torch.utils.data import DataLoader
from ..contracts import (
+ BatchCapability,
InputMediaBinding,
PipelineIOContract,
validate_pipeline_model_input,
@@ -147,6 +148,14 @@ def build_offline_train_dataloader(
data_args = config.data_args
training_args = config.training_args
+ if (
+ pipeline_io_contract.batch_capability is BatchCapability.SINGLE_SAMPLE
+ and training_args.per_device_batch_size != 1
+ ):
+ raise ValueError(
+ "offline pipeline contract requires per_device_batch_size=1 for "
+ f"single-sample execution, received {training_args.per_device_batch_size!r}"
+ )
if not data_args.enable_preprocess:
raise ValueError(
"offline train data requires data.enable_preprocess=True so cached rows contain "
diff --git a/tests/data_utils/test_offline_train_data.py b/tests/data_utils/test_offline_train_data.py
index bd24e5da4..686942c1d 100644
--- a/tests/data_utils/test_offline_train_data.py
+++ b/tests/data_utils/test_offline_train_data.py
@@ -15,6 +15,7 @@
import gc
import json
import weakref
+from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List
@@ -484,6 +485,35 @@ def test_builder_rejects_preprocessor_binding_drift_before_dataset_io(tmp_path:
)
+def test_builder_rejects_single_sample_pipeline_batching_before_dataset_io(
+ tmp_path: Path,
+) -> None:
+ """Adapter batching capability is enforced before cache or manifest work."""
+ contract = replace(
+ _TEXT_TO_IMAGE_CONTRACT,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
+ preprocessor = _CountingPreprocessor()
+
+ with pytest.raises(
+ ValueError,
+ match=r"requires per_device_batch_size=1.*received 2",
+ ):
+ build_offline_train_dataloader(
+ _config(
+ tmp_path,
+ [_source("missing", tmp_path / "missing", 0)],
+ per_device_batch_size=2,
+ ),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=contract,
+ )
+
+ assert preprocessor.calls == 0
+
+
def test_builder_rejects_non_unit_weight_and_unresolved_source_id_before_io(
tmp_path: Path,
) -> None:
From 94285ee7afb6244d166c45bcf695b0872b72b25f Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 19:27:29 +0800
Subject: [PATCH 19/76] fix(data): harden deterministic offline loading
---
src/flow_factory/data_utils/dataset.py | 196 ++++++++++++++-
src/flow_factory/data_utils/multi_source.py | 19 +-
.../data_utils/offline_condition_cache.py | 14 +-
.../data_utils/offline_dataset.py | 96 +++++++-
src/flow_factory/data_utils/offline_loader.py | 16 +-
.../data_utils/offline_train_data.py | 53 +++-
src/flow_factory/data_utils/schema.py | 20 +-
tests/data_utils/test_multi_source.py | 47 ++++
.../test_offline_condition_cache.py | 233 +++++++++++++++++-
tests/data_utils/test_offline_dataset.py | 173 ++++++++++++-
tests/data_utils/test_offline_loader.py | 4 +-
tests/data_utils/test_offline_train_data.py | 69 ++++++
tests/data_utils/test_schema.py | 49 ++--
13 files changed, 927 insertions(+), 62 deletions(-)
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index e7c0f0ae4..97bb2ff96 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -19,14 +19,16 @@
import logging
import math
import os
+import random
import shutil
from dataclasses import asdict
-from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Sequence, Union
+from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Protocol, Sequence, Union
import imageio.v3 as iio
import numpy as np
import torch
from datasets import Dataset as HFDataset
+from datasets import Features as HFFeatures
from datasets import Image as HFImage
from datasets import Sequence as HFSequence
from datasets import load_dataset, load_from_disk
@@ -373,6 +375,16 @@ def _preprocess_dataset(
if target_arrow_path is not None:
os.makedirs(os.path.dirname(os.path.abspath(target_arrow_path)), exist_ok=True)
+ preprocess_features = self._infer_cross_chunk_features(
+ raw_dataset=raw_dataset,
+ preprocessing_batch_size=preprocessing_batch_size,
+ image_dir=self.image_dir,
+ video_dir=self.video_dir,
+ audio_dir=self.audio_dir,
+ force_reprocess=force_reprocess,
+ target_arrow_path=target_arrow_path,
+ )
+
processed_dataset = raw_dataset.map(
self._preprocess_batch,
batched=True,
@@ -386,6 +398,7 @@ def _preprocess_dataset(
remove_columns=raw_dataset.column_names,
new_fingerprint=shard_fingerprint,
cache_file_name=target_arrow_path,
+ features=preprocess_features,
desc=desc,
load_from_cache_file=not force_reprocess,
)
@@ -394,6 +407,106 @@ def _preprocess_dataset(
return processed_dataset
+ def _infer_cross_chunk_features(
+ self,
+ *,
+ raw_dataset: HFDataset,
+ preprocessing_batch_size: int,
+ image_dir: Optional[str],
+ video_dir: Optional[str],
+ audio_dir: Optional[str],
+ force_reprocess: bool,
+ target_arrow_path: Optional[str],
+ ) -> Optional[HFFeatures]:
+ """Infer one explicit schema when later map chunks introduce typed values.
+
+ HuggingFace infers a batched map's writer schema from its first output
+ chunk. Optional columns therefore need a representative schema probe when
+ that chunk is empty but a later chunk is populated. This narrow probe may
+ call a preprocessor before the real ordered map, so preprocessors must keep
+ their existing cache-oriented determinism contract. Global Python, NumPy,
+ torch, MPS, and explicit ``torch.Generator`` states are restored; arbitrary
+ adapter-owned mutable state is intentionally outside that guarantee.
+
+ Args:
+ raw_dataset: Input dataset after any distributed sharding.
+ preprocessing_batch_size: Map chunk size.
+ image_dir: Image root forwarded to preprocessing.
+ video_dir: Video root forwarded to preprocessing.
+ audio_dir: Audio root forwarded to preprocessing.
+ force_reprocess: Whether an existing explicit Arrow target is rebuilt.
+ target_arrow_path: Optional explicit Arrow cache target.
+
+ Returns:
+ Explicit output features for a cross-chunk nullable transition, or
+ ``None`` when first-chunk inference is already sufficient.
+ """
+ if (
+ not force_reprocess
+ and target_arrow_path is not None
+ and os.path.isfile(target_arrow_path)
+ ):
+ return None
+
+ probe_batches = _cross_chunk_schema_probe_batches(
+ raw_dataset,
+ preprocessing_batch_size=preprocessing_batch_size,
+ )
+ if probe_batches is None:
+ return None
+
+ explicit_generators = list(_iter_torch_generators(self._preprocess_kwargs))
+ generator_states = [generator.get_state() for generator in explicit_generators]
+ python_state = random.getstate()
+ numpy_state = np.random.get_state()
+ mps_state = None
+ if torch.backends.mps.is_available() and hasattr(torch.mps, "get_rng_state"):
+ mps_state = torch.mps.get_rng_state()
+
+ try:
+ with torch.random.fork_rng():
+ probe_results = [
+ (
+ indices,
+ self._preprocess_batch(
+ raw_dataset[indices],
+ indices,
+ image_dir,
+ video_dir,
+ audio_dir,
+ ),
+ )
+ for indices in probe_batches
+ ]
+ finally:
+ random.setstate(python_state)
+ np.random.set_state(numpy_state)
+ if mps_state is not None:
+ torch.mps.set_rng_state(mps_state)
+ for generator, state in zip(explicit_generators, generator_states):
+ generator.set_state(state)
+
+ expected_columns = tuple(probe_results[0][1])
+ combined_result = {column_name: [] for column_name in expected_columns}
+ for indices, probe_result in probe_results:
+ if set(probe_result) != set(expected_columns):
+ raise ValueError(
+ "preprocess output columns must remain stable across map chunks: "
+ f"expected {expected_columns!r}, got {tuple(probe_result)!r} "
+ f"for source rows {indices!r}"
+ )
+ for column_name in expected_columns:
+ values = probe_result[column_name]
+ if not isinstance(values, (list, tuple, np.ndarray)):
+ raise TypeError(
+ "batched preprocess output columns must be sequences, "
+ f"got {type(values).__name__} for {column_name!r} "
+ f"at source rows {indices!r}"
+ )
+ combined_result[column_name].extend(values)
+
+ return HFDataset.from_dict(combined_result).features
+
def _shard_dataset(self, dataset: HFDataset, shard_index: int, num_shards: int) -> HFDataset:
"""
Split dataset into shards for distributed preprocessing.
@@ -1010,6 +1123,87 @@ def _normalize_passthrough_columns(columns: Optional[Sequence[str]]) -> tuple[st
return normalized
+def _cross_chunk_schema_probe_batches(
+ dataset: HFDataset,
+ *,
+ preprocessing_batch_size: int,
+) -> Optional[List[List[int]]]:
+ """Return real map chunks needed to cover later typed source values."""
+ if preprocessing_batch_size <= 0 or len(dataset) <= preprocessing_batch_size:
+ return None
+
+ first_chunk_stop = min(preprocessing_batch_size, len(dataset))
+ first_chunk = dataset[:first_chunk_stop]
+ pending_columns = {
+ column_name
+ for column_name in dataset.column_names
+ if not any(_has_typed_payload(value) for value in first_chunk[column_name])
+ }
+ if not pending_columns:
+ return None
+
+ probe_chunk_starts = {0}
+ for start in range(first_chunk_stop, len(dataset), preprocessing_batch_size):
+ stop = min(start + preprocessing_batch_size, len(dataset))
+ pending_chunk = dataset.select_columns(sorted(pending_columns))[start:stop]
+ typed_columns = {
+ column_name
+ for column_name in pending_columns
+ if any(_has_typed_payload(value) for value in pending_chunk[column_name])
+ }
+ if typed_columns:
+ probe_chunk_starts.add(start)
+ pending_columns.difference_update(typed_columns)
+ if not pending_columns:
+ break
+
+ if len(probe_chunk_starts) == 1:
+ return None
+ return [
+ list(range(start, min(start + preprocessing_batch_size, len(dataset))))
+ for start in sorted(probe_chunk_starts)
+ ]
+
+
+def _has_typed_payload(value: Any) -> bool:
+ """Return whether a value can contribute a non-null Arrow child type."""
+ if value is None:
+ return False
+ if isinstance(value, (str, bytes)):
+ return bool(value)
+ if isinstance(value, torch.Tensor):
+ return value.numel() > 0
+ if isinstance(value, np.ndarray):
+ return value.size > 0
+ if isinstance(value, Mapping):
+ return any(_has_typed_payload(item) for item in value.values())
+ if isinstance(value, (list, tuple)):
+ return any(_has_typed_payload(item) for item in value)
+ return True
+
+
+def _iter_torch_generators(
+ value: Any,
+ _seen: Optional[set[int]] = None,
+) -> Iterator[torch.Generator]:
+ """Yield distinct explicit torch generators nested in preprocessing kwargs."""
+ if _seen is None:
+ _seen = set()
+ value_id = id(value)
+ if value_id in _seen:
+ return
+ _seen.add(value_id)
+
+ if isinstance(value, torch.Generator):
+ yield value
+ elif isinstance(value, Mapping):
+ for item in value.values():
+ yield from _iter_torch_generators(item, _seen)
+ elif isinstance(value, (list, tuple)):
+ for item in value:
+ yield from _iter_torch_generators(item, _seen)
+
+
def _supports_ordered_references(preprocess_func: Optional[Callable]) -> bool:
"""Return whether a bound preprocessor explicitly opts into ordered references."""
if preprocess_func is None:
diff --git a/src/flow_factory/data_utils/multi_source.py b/src/flow_factory/data_utils/multi_source.py
index 4d808decf..2f4232ab1 100644
--- a/src/flow_factory/data_utils/multi_source.py
+++ b/src/flow_factory/data_utils/multi_source.py
@@ -21,19 +21,28 @@
training source is declared.
"""
+import hashlib
from typing import Any, Dict, Iterator, List, Optional
import torch
from torch.utils.data import DataLoader
+_SCHEDULE_SEED_DOMAIN = "flow_factory.multi_source_schedule.v1"
+
+
+def _stable_schedule_seed(seed: int, epoch: int) -> int:
+ """Derive a process-independent seed for one source-schedule epoch."""
+ payload = f"{_SCHEDULE_SEED_DOMAIN}:{seed}:{epoch}".encode("ascii")
+ return int.from_bytes(hashlib.sha256(payload).digest()[:8], byteorder="big", signed=False)
+
class WeightedSourceBatchScheduler:
"""Deterministic shared-across-ranks list of source names, length per epoch.
Built by repeating each source's ``num_batches_per_source[name]`` times
- and shuffling under a ``torch.Generator`` seeded by ``seed + epoch``.
- All ranks see the same list every epoch (constructor takes only seed +
- counts; no rank-dependent randomness).
+ and shuffling under a ``torch.Generator`` seeded by a stable digest of
+ ``seed`` and ``epoch``. All ranks see the same list every epoch
+ (constructor takes only seed + counts; no rank-dependent randomness).
The input dict's iteration order is **ignored** — sources are processed
in ``sorted(name)`` order so the generated schedule is byte-identical
@@ -67,9 +76,7 @@ def _build(self) -> None:
return
g = torch.Generator()
- g.manual_seed(
- hash((self._seed, self._epoch, "multi_source_schedule")) & 0xFFFF_FFFF_FFFF_FFFF
- )
+ g.manual_seed(_stable_schedule_seed(self._seed, self._epoch))
perm = torch.randperm(len(flat), generator=g).tolist()
self._schedule = [flat[i] for i in perm]
diff --git a/src/flow_factory/data_utils/offline_condition_cache.py b/src/flow_factory/data_utils/offline_condition_cache.py
index 1bb6ec724..4d0ea3dcf 100644
--- a/src/flow_factory/data_utils/offline_condition_cache.py
+++ b/src/flow_factory/data_utils/offline_condition_cache.py
@@ -26,7 +26,7 @@
import hashlib
import json
import os
-from typing import Any, Dict, List, Mapping, Sequence
+from typing import Any, Dict, List, Mapping, MutableMapping, Sequence
from datasets import Dataset as HFDataset
@@ -51,6 +51,7 @@ def project_offline_condition_dataset(
*,
source_name: str,
ordered_references: bool,
+ _media_digest_cache: MutableMapping[str, str] | None = None,
) -> HFDataset:
"""Build an input-only raw dataset for ``GeneralDataset`` preprocessing.
@@ -74,11 +75,15 @@ def project_offline_condition_dataset(
f"got {type(record).__name__} at index {index}"
)
+ media_digest_cache: MutableMapping[str, str] = (
+ {} if _media_digest_cache is None else _media_digest_cache
+ )
condition_ids = [
compute_offline_condition_id(
record,
index=index,
source_name=source_name,
+ _media_digest_cache=media_digest_cache,
)
for index, record in enumerate(stable_records)
]
@@ -89,7 +94,12 @@ def project_offline_condition_dataset(
negative_prompts = [record.model_input.negative_prompt for record in stable_records]
if any(value is not None for value in negative_prompts):
- columns["negative_prompt"] = negative_prompts
+ # Adapter tokenizers consume a homogeneous text batch. In a mixed V2
+ # batch, an omitted optional negative prompt is semantically the empty
+ # prompt, not a tokenizer-level ``None`` value.
+ columns["negative_prompt"] = [
+ value if value is not None else "" for value in negative_prompts
+ ]
if ordered_references:
columns["references"] = [
diff --git a/src/flow_factory/data_utils/offline_dataset.py b/src/flow_factory/data_utils/offline_dataset.py
index c38081f78..0822179bf 100644
--- a/src/flow_factory/data_utils/offline_dataset.py
+++ b/src/flow_factory/data_utils/offline_dataset.py
@@ -30,7 +30,7 @@
import pickle
from dataclasses import dataclass
from types import MappingProxyType
-from typing import Any, Callable, Dict, List, Literal, Mapping, Sequence, Union
+from typing import Any, Callable, Dict, List, Literal, Mapping, MutableMapping, Sequence, Union
import numpy as np
import torch
@@ -59,6 +59,7 @@
MediaDecoder = Callable[[MediaAsset], Any]
ConditionCache = Union[Dataset, Sequence[Mapping[str, Any]]]
OFFLINE_CONDITION_ID_COLUMN = "__offline_condition_id__"
+_MEDIA_CONTENT_HASH_CHUNK_SIZE = 1024 * 1024
@dataclass(frozen=True, slots=True)
@@ -216,6 +217,7 @@ def __init__(
source_id: int,
supervision_type: OfflineSupervisionType,
media_decoders: Mapping[MediaType, MediaDecoder] | None = None,
+ _media_digest_cache: MutableMapping[str, str] | None = None,
) -> None:
_validate_supervision_type(supervision_type)
if not isinstance(source_name, str) or not source_name.strip():
@@ -263,16 +265,21 @@ def __init__(
condition_ids: List[str] = []
record_ids: List[str] = []
+ media_digest_cache: MutableMapping[str, str] = (
+ {} if _media_digest_cache is None else _media_digest_cache
+ )
for index, record in enumerate(normalized_records):
condition_id = compute_offline_condition_id(
record,
index=index,
source_name=source_name,
+ _media_digest_cache=media_digest_cache,
)
record_id = compute_offline_record_id(
record,
index=index,
source_name=source_name,
+ _media_digest_cache=media_digest_cache,
)
_extract_condition(
stable_condition_cache[index],
@@ -517,9 +524,18 @@ def compute_offline_condition_id(
*,
index: int,
source_name: str,
+ _media_digest_cache: MutableMapping[str, str] | None = None,
) -> str:
- """Build an input-only identity for one prompt/condition cache row."""
+ """Build an input-only identity for one prompt/condition cache row.
+
+ Media bytes are streamed into the identity. Callers building several rows may
+ supply one private path-to-digest memo so a repeated asset is read only once;
+ the ordinary standalone call remains self-contained.
+ """
_validate_identity_inputs(record, index=index, source_name=source_name)
+ media_digest_cache: MutableMapping[str, str] = (
+ {} if _media_digest_cache is None else _media_digest_cache
+ )
identity_payload = {
"source_name": source_name,
"index": index,
@@ -527,7 +543,10 @@ def compute_offline_condition_id(
"input": {
"prompt": record.model_input.prompt,
"negative_prompt": record.model_input.negative_prompt,
- "media": [_media_identity(media) for media in record.model_input.media],
+ "media": [
+ _media_identity(media, media_digest_cache=media_digest_cache)
+ for media in record.model_input.media
+ ],
},
}
return _hash_identity(identity_payload)
@@ -538,24 +557,38 @@ def compute_offline_record_id(
*,
index: int,
source_name: str,
+ _media_digest_cache: MutableMapping[str, str] | None = None,
) -> str:
- """Build a full provenance identity including supervision and metadata."""
+ """Build a full provenance identity including supervision bytes and metadata."""
+ media_digest_cache: MutableMapping[str, str] = (
+ {} if _media_digest_cache is None else _media_digest_cache
+ )
condition_id = compute_offline_condition_id(
record,
index=index,
source_name=source_name,
+ _media_digest_cache=media_digest_cache,
)
supervision = record.supervision
if isinstance(supervision, DemonstrationSupervision):
supervision_payload: Any = {
"type": "demonstration",
- "target": _candidate_identity(supervision.target),
+ "target": _candidate_identity(
+ supervision.target,
+ media_digest_cache=media_digest_cache,
+ ),
}
elif isinstance(supervision, PreferenceSupervision):
supervision_payload = {
"type": "preference",
- "chosen": _candidate_identity(supervision.chosen),
- "rejected": _candidate_identity(supervision.rejected),
+ "chosen": _candidate_identity(
+ supervision.chosen,
+ media_digest_cache=media_digest_cache,
+ ),
+ "rejected": _candidate_identity(
+ supervision.rejected,
+ media_digest_cache=media_digest_cache,
+ ),
}
else:
supervision_payload = None
@@ -595,19 +628,62 @@ def _hash_identity(identity_payload: Mapping[str, Any]) -> str:
return hashlib.sha256(canonical_identity.encode("utf-8")).hexdigest()
-def _candidate_identity(candidate: NormalizedOutputCandidate) -> Dict[str, Any]:
- return {"media": [_media_identity(media) for media in candidate.media]}
+def _candidate_identity(
+ candidate: NormalizedOutputCandidate,
+ *,
+ media_digest_cache: MutableMapping[str, str],
+) -> Dict[str, Any]:
+ return {
+ "media": [
+ _media_identity(media, media_digest_cache=media_digest_cache)
+ for media in candidate.media
+ ]
+ }
-def _media_identity(media: MediaAsset) -> Dict[str, Any]:
+def _media_identity(
+ media: MediaAsset,
+ *,
+ media_digest_cache: MutableMapping[str, str],
+) -> Dict[str, Any]:
return {
"type": media.type,
"path": media.path,
"fps": media.fps,
"sample_rate": media.sample_rate,
+ "content_sha256": _media_content_sha256(
+ media.path,
+ media_digest_cache=media_digest_cache,
+ ),
}
+def _media_content_sha256(
+ path: str,
+ *,
+ media_digest_cache: MutableMapping[str, str],
+) -> str:
+ """Return one memoized content digest for a normalized local media path."""
+ cached = media_digest_cache.get(path)
+ if cached is not None:
+ return cached
+ content_digest = _stream_media_content_sha256(path)
+ media_digest_cache[path] = content_digest
+ return content_digest
+
+
+def _stream_media_content_sha256(path: str) -> str:
+ """Hash one media file incrementally without decoding or retaining its bytes."""
+ digest = hashlib.sha256()
+ try:
+ with open(path, "rb") as media_file:
+ while chunk := media_file.read(_MEDIA_CONTENT_HASH_CHUNK_SIZE):
+ digest.update(chunk)
+ except OSError as error:
+ raise OSError(f"failed to hash offline media content at {path!r}: {error}") from error
+ return digest.hexdigest()
+
+
def _extract_condition(
row: Any,
*,
diff --git a/src/flow_factory/data_utils/offline_loader.py b/src/flow_factory/data_utils/offline_loader.py
index 7501b239a..f35a30e0a 100644
--- a/src/flow_factory/data_utils/offline_loader.py
+++ b/src/flow_factory/data_utils/offline_loader.py
@@ -42,11 +42,13 @@ def build_offline_dataloader(
) -> DataLoader:
"""Build one finite, already-distributed offline epoch loader.
- Every source participates exactly once through a :class:`ConcatDataset`.
- Weighted replacement would make "one complete dataloader traversal" stop
- meaning one data epoch, so all source weights must be explicitly equal to
- one. The returned loader is not passed through any Accelerator preparation;
- its official :class:`DistributedSampler` already owns rank sharding.
+ Every source is concatenated once through a :class:`ConcatDataset`. Weighted
+ replacement would make "one complete dataloader traversal" stop meaning one
+ data epoch, so all source weights must be explicitly equal to one. The returned
+ loader is not passed through any Accelerator preparation; its official
+ :class:`DistributedSampler` already owns rank sharding. With
+ ``sampler_drop_last=False``, PyTorch may repeat tail indices so every rank has
+ the same length; that standard sampler behavior remains part of the traversal.
The execution driver, not this builder, calls ``sampler.set_epoch`` before
each traversal.
@@ -131,8 +133,8 @@ def build_offline_dataloader(
raise ValueError(
f"offline dataloader yields {num_batches} batches on rank {rank}, which is not "
f"divisible by gradient_accumulation_steps={gradient_accumulation_steps}. "
- "Offline epochs do not pad the loader or implicitly flush a partial gradient-"
- "accumulation window; adjust dataset size, batch_size, num_replicas, "
+ "Offline training does not add batches solely to close or implicitly flush a "
+ "partial gradient-accumulation window; adjust dataset size, batch_size, num_replicas, "
"sampler_drop_last, batch_drop_last, or gradient_accumulation_steps explicitly."
)
return loader
diff --git a/src/flow_factory/data_utils/offline_train_data.py b/src/flow_factory/data_utils/offline_train_data.py
index 637ff84ef..83f6d9493 100644
--- a/src/flow_factory/data_utils/offline_train_data.py
+++ b/src/flow_factory/data_utils/offline_train_data.py
@@ -16,9 +16,11 @@
from __future__ import annotations
+import json
import os
-from typing import Any, Literal, Mapping, Optional, Sequence
+from typing import Any, Literal, Mapping, MutableMapping, Optional, Sequence
+import torch
from accelerate import Accelerator
from datasets import Dataset as HFDataset
from torch.utils.data import DataLoader
@@ -211,6 +213,7 @@ def build_offline_train_dataloader(
offline_datasets = []
for source, dataset_dir, records in source_records:
+ media_digest_cache: MutableMapping[str, str] = {}
condition_cache = _build_distributed_condition_cache(
records,
source_name=source.name,
@@ -224,6 +227,7 @@ def build_offline_train_dataloader(
extra_hash_strs=[*normalized_extra_hash_strs, f"offline_train_source:{source.name}"],
preprocess_parallelism=data_args.preprocess_parallelism,
accelerator=accelerator,
+ _media_digest_cache=media_digest_cache,
)
offline_datasets.append(
OfflineDataset(
@@ -233,6 +237,7 @@ def build_offline_train_dataloader(
source_id=source.source_id,
supervision_type=supervision_type,
media_decoders=media_decoders,
+ _media_digest_cache=media_digest_cache,
)
)
@@ -313,6 +318,7 @@ def _build_distributed_condition_cache(
extra_hash_strs: Sequence[str],
preprocess_parallelism: Literal["global", "local"],
accelerator: Accelerator,
+ _media_digest_cache: MutableMapping[str, str] | None = None,
) -> HFDataset:
"""Build one input-only cache through the existing rank-safe orchestrator."""
ordered_references = _supports_ordered_references(preprocess_func)
@@ -321,6 +327,7 @@ def _build_distributed_condition_cache(
records,
source_name=source_name,
ordered_references=ordered_references,
+ _media_digest_cache=_media_digest_cache,
)
condition_ids = tuple(raw_dataset[OFFLINE_CONDITION_ID_COLUMN])
source_hash = compute_offline_condition_source_hash(condition_ids)
@@ -372,7 +379,25 @@ def _build_extra_hash_strs(
config: Arguments,
extra_hash_strs: Optional[Sequence[str]],
) -> list[str]:
- values = [config.model_args.model_type, config.model_args.model_name_or_path]
+ model_args = config.model_args
+ precision_policy = json.dumps(
+ {
+ "component_load_dtypes": _canonical_dtype_policy(
+ getattr(model_args, "component_load_dtypes", None)
+ ),
+ "frozen_parameters_dtype": _canonical_dtype_policy(
+ getattr(model_args, "frozen_parameters_dtype", None)
+ ),
+ },
+ allow_nan=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ values = [
+ model_args.model_type,
+ model_args.model_name_or_path,
+ f"preprocess_precision:{precision_policy}",
+ ]
if extra_hash_strs is not None:
values.extend(extra_hash_strs)
for index, value in enumerate(values):
@@ -384,6 +409,30 @@ def _build_extra_hash_strs(
return values
+def _canonical_dtype_policy(value: Any) -> Any:
+ """Return stable JSON data for one normalized model dtype policy."""
+ if value is None:
+ return None
+ if isinstance(value, torch.dtype):
+ return str(value).split(".")[-1]
+ if isinstance(value, str):
+ return value
+ if isinstance(value, Mapping):
+ canonical = {}
+ for selector in sorted(value):
+ if not isinstance(selector, str) or not selector:
+ raise TypeError(
+ "offline preprocessing dtype policy selectors must be non-empty strings, "
+ f"got {selector!r}"
+ )
+ canonical[selector] = _canonical_dtype_policy(value[selector])
+ return canonical
+ raise TypeError(
+ "offline preprocessing dtype policy must be a dtype, string, mapping, or None, "
+ f"got {type(value).__name__}: {value!r}"
+ )
+
+
def _validate_training_sources(sources: Sequence[Any]) -> None:
if not sources:
raise ValueError("offline train data requires at least one enabled data.datasets source")
diff --git a/src/flow_factory/data_utils/schema.py b/src/flow_factory/data_utils/schema.py
index 28b9d3670..de80b12f4 100644
--- a/src/flow_factory/data_utils/schema.py
+++ b/src/flow_factory/data_utils/schema.py
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Strict public schema for version-two dataset records.
+"""Strict public schema for version-two supervised offline dataset records.
This module owns only the JSON boundary and its model-agnostic normalized
-representation. It deliberately does not parse legacy records, decode media,
-or attach loader-owned identities such as source ids and row ids.
+representation. It deliberately does not parse legacy online-generation records,
+decode media, or attach loader-owned identities such as source ids and row ids.
"""
from __future__ import annotations
@@ -88,7 +88,7 @@ class AudioRef(_MediaRefBase):
class InputSpec(_StrictFrozenModel):
- """Generation conditions shared by online and offline algorithms."""
+ """Model input shared by the supervised offline algorithm families."""
prompt: str
negative_prompt: str | None = None
@@ -123,11 +123,11 @@ class PreferenceSpec(_StrictFrozenModel):
class DatasetRecordV2(_StrictFrozenModel):
- """Strict V2 JSONL record for online, demonstration, or preference data."""
+ """Strict V2 JSONL record for demonstration or preference data."""
schema_version: Literal[2]
input: InputSpec
- supervision: SupervisionSpec | None = None
+ supervision: SupervisionSpec
metadata: Dict[str, JsonValue] = Field(default_factory=dict)
@@ -172,12 +172,12 @@ class PreferenceSupervision:
rejected: NormalizedOutputCandidate
-NormalizedSupervision = Union[DemonstrationSupervision, PreferenceSupervision, None]
+NormalizedSupervision = Union[DemonstrationSupervision, PreferenceSupervision]
@dataclass(frozen=True, slots=True)
class NormalizedDatasetRecord:
- """Immutable schema facts, excluding identities owned by a dataset loader."""
+ """Immutable supervised schema facts, excluding loader-owned identities."""
model_input: NormalizedModelInput
supervision: NormalizedSupervision
@@ -213,9 +213,7 @@ def normalize_v2_record(
)
supervision: NormalizedSupervision
- if parsed.supervision is None:
- supervision = None
- elif isinstance(parsed.supervision, DemonstrationSpec):
+ if isinstance(parsed.supervision, DemonstrationSpec):
supervision = DemonstrationSupervision(
target=_normalize_candidate(parsed.supervision.target, dataset_dir)
)
diff --git a/tests/data_utils/test_multi_source.py b/tests/data_utils/test_multi_source.py
index c64d3fec9..084344f86 100644
--- a/tests/data_utils/test_multi_source.py
+++ b/tests/data_utils/test_multi_source.py
@@ -1,8 +1,48 @@
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+from typing import List
+
from flow_factory.data_utils.multi_source import (
MultiSourceTrainDataLoader,
WeightedSourceBatchScheduler,
)
+_SUBPROCESS_SCHEDULE_SCRIPT = """
+import json
+
+from flow_factory.data_utils.multi_source import WeightedSourceBatchScheduler
+
+scheduler = WeightedSourceBatchScheduler(
+ {"alpha": 8, "beta": 8, "gamma": 8},
+ seed=17,
+)
+scheduler.set_epoch(3)
+print(json.dumps(list(scheduler)))
+"""
+
+
+def _schedule_from_fresh_process(python_hash_seed: int) -> List[str]:
+ env = os.environ.copy()
+ env["PYTHONHASHSEED"] = str(python_hash_seed)
+ source_root = str(Path(__file__).resolve().parents[2] / "src")
+ inherited_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ os.pathsep.join((source_root, inherited_pythonpath))
+ if inherited_pythonpath
+ else source_root
+ )
+ completed = subprocess.run(
+ [sys.executable, "-c", _SUBPROCESS_SCHEDULE_SCRIPT],
+ check=True,
+ capture_output=True,
+ env=env,
+ text=True,
+ )
+ return json.loads(completed.stdout)
+
def test_multi_source_loader_exposes_batch_size_for_deepspeed() -> None:
loader = MultiSourceTrainDataLoader(
@@ -12,3 +52,10 @@ def test_multi_source_loader_exposes_batch_size_for_deepspeed() -> None:
)
assert loader.batch_size == 3
+
+
+def test_source_schedule_is_independent_of_process_hash_seed() -> None:
+ first = _schedule_from_fresh_process(python_hash_seed=1)
+ second = _schedule_from_fresh_process(python_hash_seed=2)
+
+ assert first == second
diff --git a/tests/data_utils/test_offline_condition_cache.py b/tests/data_utils/test_offline_condition_cache.py
index d2b270716..926c6fb6f 100644
--- a/tests/data_utils/test_offline_condition_cache.py
+++ b/tests/data_utils/test_offline_condition_cache.py
@@ -22,9 +22,10 @@
import pytest
import torch
from datasets import Dataset as HFDataset
+from datasets import Image as HFImage
from PIL import Image
-from flow_factory.data_utils.dataset import GeneralDataset
+from flow_factory.data_utils.dataset import GeneralDataset, _cross_chunk_schema_probe_batches
from flow_factory.data_utils.offline_condition_cache import (
build_offline_condition_cache,
compute_offline_condition_source_hash,
@@ -87,6 +88,73 @@ def preprocess(self, prompt: List[str]) -> Dict[str, List[str]]:
return {OFFLINE_CONDITION_ID_COLUMN: ["adapter-owned"] * len(prompt)}
+class OptionalImagePreprocessor:
+ python_format_columns = frozenset({"condition_images"})
+
+ def preprocess(
+ self,
+ prompt: List[str],
+ images: List[List[Image.Image]],
+ ) -> Dict[str, Any]:
+ condition_images = []
+ image_latents = []
+ image_latent_ids = []
+ for sample_images in images:
+ condition_images.append(sample_images)
+ if sample_images:
+ image_latents.append(torch.ones(2, 3))
+ image_latent_ids.append(torch.zeros(2, 4))
+ else:
+ image_latents.append(None)
+ image_latent_ids.append(None)
+ return {
+ "condition_images": condition_images,
+ "image_latents": image_latents,
+ "image_latent_ids": image_latent_ids,
+ }
+
+
+class ChunkBoundedDatasetSpy:
+ def __init__(
+ self,
+ rows: List[Dict[str, Any]],
+ *,
+ columns: List[str] | None = None,
+ accesses: List[tuple[tuple[str, ...], int, int]] | None = None,
+ max_chunk_size: int = 2,
+ ) -> None:
+ self.rows = rows
+ self.column_names = list(rows[0]) if columns is None else columns
+ self.accesses = [] if accesses is None else accesses
+ self.max_chunk_size = max_chunk_size
+
+ def __len__(self) -> int:
+ return len(self.rows)
+
+ def select_columns(self, column_names: List[str]) -> "ChunkBoundedDatasetSpy":
+ return ChunkBoundedDatasetSpy(
+ self.rows,
+ columns=column_names,
+ accesses=self.accesses,
+ max_chunk_size=self.max_chunk_size,
+ )
+
+ def __getitem__(self, key: slice) -> Dict[str, List[Any]]:
+ if not isinstance(key, slice):
+ raise AssertionError(f"schema discovery requested an unbounded column: {key!r}")
+ start = 0 if key.start is None else key.start
+ stop = len(self.rows) if key.stop is None else key.stop
+ if key.step not in (None, 1):
+ raise AssertionError(f"schema discovery requested a strided slice: {key!r}")
+ if stop - start > self.max_chunk_size:
+ raise AssertionError(f"schema discovery requested an oversized slice: {key!r}")
+ self.accesses.append((tuple(self.column_names), start, stop))
+ return {
+ column_name: [self.rows[index][column_name] for index in range(start, stop)]
+ for column_name in self.column_names
+ }
+
+
def _demonstration_record(
dataset_dir: Path,
*,
@@ -175,6 +243,41 @@ def test_target_and_metadata_changes_reuse_the_input_only_cache(tmp_path: Path)
assert "second-target-does-not-exist.png" not in repr(second_cache[0])
+def test_input_media_replacement_invalidates_the_condition_cache(tmp_path: Path) -> None:
+ input_path = tmp_path / "reference.png"
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(input_path)
+ record = _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "reference.png"}],
+ target_path="target-does-not-enter-condition-cache.png",
+ )
+ preprocessor = CountingPreprocessor()
+
+ first_cache = build_offline_condition_cache(
+ [record],
+ source_name="content-addressed-input",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ preprocessing_batch_size=1,
+ )
+ first_condition_id = first_cache[0][OFFLINE_CONDITION_ID_COLUMN]
+ Image.new("RGB", (2, 2), color=(4, 5, 6)).save(input_path)
+ second_cache = build_offline_condition_cache(
+ [record],
+ source_name="content-addressed-input",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ preprocessing_batch_size=1,
+ )
+
+ assert preprocessor.calls == 2
+ assert second_cache[0][OFFLINE_CONDITION_ID_COLUMN] != first_condition_id
+ assert second_cache._fingerprint != first_cache._fingerprint
+ assert second_cache.cache_files != first_cache.cache_files
+
+
def test_condition_cache_does_not_retain_the_bound_preprocessor(tmp_path: Path) -> None:
preprocessor = CountingPreprocessor()
preprocessor_ref = weakref.ref(preprocessor)
@@ -196,6 +299,7 @@ def test_condition_cache_does_not_retain_the_bound_preprocessor(tmp_path: Path)
def test_projection_contains_only_input_and_identity_columns(tmp_path: Path) -> None:
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "reference.png")
record = _demonstration_record(
tmp_path,
input_media=[{"type": "image", "path": "reference.png"}],
@@ -220,6 +324,22 @@ def test_projection_contains_only_input_and_identity_columns(tmp_path: Path) ->
assert "private-target.png" not in repr(projected[0])
+def test_projection_normalizes_missing_optional_negative_prompts_in_mixed_batch(
+ tmp_path: Path,
+) -> None:
+ """Tokenizers receive strings when only some V2 rows specify a negative prompt."""
+ projected = project_offline_condition_dataset(
+ [
+ _demonstration_record(tmp_path, negative_prompt=None),
+ _demonstration_record(tmp_path, negative_prompt="bad quality"),
+ ],
+ source_name="mixed-negative-prompts",
+ ordered_references=False,
+ )
+
+ assert projected["negative_prompt"] == ["", "bad quality"]
+
+
def test_preference_arms_never_enter_the_condition_projection(tmp_path: Path) -> None:
projected = project_offline_condition_dataset(
[_preference_record(tmp_path)],
@@ -239,12 +359,40 @@ def test_condition_source_hash_is_order_sensitive() -> None:
)
+def test_schema_probe_scans_only_bounded_pending_column_chunks() -> None:
+ dataset = ChunkBoundedDatasetSpy(
+ [
+ {"a": None, "b": [], "always": "set"},
+ {"a": None, "b": [], "always": "set"},
+ {"a": None, "b": [], "always": "set"},
+ {"a": "typed", "b": [], "always": "set"},
+ {"a": None, "b": ["typed"], "always": "set"},
+ {"a": None, "b": [], "always": "set"},
+ ],
+ max_chunk_size=2,
+ )
+
+ probe_batches = _cross_chunk_schema_probe_batches(
+ dataset,
+ preprocessing_batch_size=2,
+ )
+
+ assert probe_batches == [[0, 1], [2, 3], [4, 5]]
+ assert dataset.accesses == [
+ (("a", "b", "always"), 0, 2),
+ (("a", "b"), 2, 4),
+ (("b",), 4, 6),
+ ]
+
+
def test_grouped_projection_preserves_per_modality_order_and_rate_overrides(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
for name, color in (("first.png", (255, 0, 0)), ("second.png", (0, 255, 0))):
Image.new("RGB", (2, 2), color=color).save(tmp_path / name)
+ for name in ("first.mp4", "second.mp4", "voice.wav"):
+ (tmp_path / name).write_bytes(f"identity bytes for {name}".encode("utf-8"))
video_calls: List[tuple[str, float | None]] = []
audio_calls: List[tuple[str, int | None]] = []
@@ -294,11 +442,94 @@ def fake_load_audio(path: str, sample_rate: int | None = None) -> torch.Tensor:
assert len(preprocessor.audios[0]) == 1
+def test_condition_cache_preserves_optional_image_schema_across_map_chunks(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Arrow keeps one optional-image schema using datasets 3.3 map keywords."""
+ original_map = HFDataset.map
+ captured_features = []
+
+ def datasets_3_3_compatible_map(
+ self: HFDataset,
+ function: Any,
+ *,
+ batched: bool,
+ with_indices: bool,
+ batch_size: int,
+ fn_kwargs: Dict[str, Any],
+ remove_columns: List[str],
+ new_fingerprint: str,
+ cache_file_name: str,
+ features: Any,
+ desc: str,
+ load_from_cache_file: bool,
+ ) -> HFDataset:
+ captured_features.append(features)
+ return original_map(
+ self,
+ function,
+ batched=batched,
+ with_indices=with_indices,
+ batch_size=batch_size,
+ fn_kwargs=fn_kwargs,
+ remove_columns=remove_columns,
+ new_fingerprint=new_fingerprint,
+ cache_file_name=cache_file_name,
+ features=features,
+ desc=desc,
+ load_from_cache_file=load_from_cache_file,
+ )
+
+ monkeypatch.setattr(HFDataset, "map", datasets_3_3_compatible_map)
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "reference.png")
+ records = [
+ _demonstration_record(tmp_path),
+ _demonstration_record(tmp_path),
+ _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "reference.png"}],
+ ),
+ _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "reference.png"}],
+ ),
+ ]
+
+ cache = build_offline_condition_cache(
+ records,
+ source_name="optional-images",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=OptionalImagePreprocessor().preprocess,
+ force_reprocess=True,
+ preprocessing_batch_size=2,
+ )
+
+ assert len(captured_features) == 1
+ assert captured_features[0] is not None
+ assert isinstance(cache.features["condition_images"].feature, HFImage)
+ assert cache.features["image_latents"].feature.feature.dtype == "float32"
+ assert cache.features["image_latent_ids"].feature.feature.dtype == "float32"
+ assert cache[0]["condition_images"] == []
+ assert cache[0]["image_latents"] is None
+ assert cache[0]["image_latent_ids"] is None
+ assert cache[1]["condition_images"] == []
+ assert cache[1]["image_latents"] is None
+ assert cache[1]["image_latent_ids"] is None
+ for row in (2, 3):
+ assert len(cache[row]["condition_images"]) == 1
+ assert cache[row]["image_latents"].shape == (2, 3)
+ assert cache[row]["image_latent_ids"].shape == (2, 4)
+
+
def test_ordered_heterogeneous_references_cross_arrow_as_canonical_json(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "image.png")
+ (tmp_path / "video.mp4").write_bytes(b"identity-only video payload")
+ (tmp_path / "audio.wav").write_bytes(b"identity-only audio payload")
monkeypatch.setattr(
"flow_factory.data_utils.dataset._decode_ordered_video",
lambda path: (np.zeros((1, 2, 2, 3), dtype=np.uint8), 30.0, None, None),
diff --git a/tests/data_utils/test_offline_dataset.py b/tests/data_utils/test_offline_dataset.py
index 4b0aa5a91..093c9db38 100644
--- a/tests/data_utils/test_offline_dataset.py
+++ b/tests/data_utils/test_offline_dataset.py
@@ -271,7 +271,7 @@ def test_manifest_reader_reports_failing_line_context(
assert "train.jsonl:2" in message
-def test_manifest_reader_rejects_prompt_only_and_mixed_supervision(tmp_path: Path) -> None:
+def test_manifest_reader_rejects_missing_and_mixed_supervision(tmp_path: Path) -> None:
prompt_only = tmp_path / "prompt-only.jsonl"
prompt_only.write_text(
json.dumps(
@@ -283,8 +283,12 @@ def test_manifest_reader_rejects_prompt_only_and_mixed_supervision(tmp_path: Pat
+ "\n",
encoding="utf-8",
)
- with pytest.raises(ValueError, match=r"prompt-only\.jsonl:1 is prompt-only"):
+ with pytest.raises(ValueError) as exc_info:
load_offline_manifest(prompt_only, supervision_type="demonstration")
+ message = str(exc_info.value)
+ assert "invalid offline V2 record" in message
+ assert "prompt-only.jsonl:1" in message
+ assert "supervision" in message
mixed = tmp_path / "mixed.jsonl"
preference = {
@@ -380,6 +384,7 @@ def test_dataset_requires_normalized_homogeneous_records_and_matching_cache_ids(
def test_record_id_distinguishes_duplicate_rows_by_stable_index(tmp_path: Path) -> None:
+ _save_image(tmp_path / "target.png", (1, 2, 3))
record = _demonstration_record(tmp_path, ["target.png"])
first = compute_offline_record_id(record, index=0, source_name=SOURCE_NAME)
@@ -423,6 +428,8 @@ def test_dataset_decodes_target_on_every_access_and_strips_reserved_condition_id
def test_condition_id_is_input_only_while_record_id_tracks_full_provenance(
tmp_path: Path,
) -> None:
+ _save_image(tmp_path / "first.png", (1, 2, 3))
+ _save_image(tmp_path / "second.png", (4, 5, 6))
first = normalize_v2_record(
{
"schema_version": 2,
@@ -456,6 +463,163 @@ def test_condition_id_is_input_only_while_record_id_tracks_full_provenance(
) != compute_offline_record_id(second, index=0, source_name=SOURCE_NAME)
+def test_same_path_media_replacement_changes_the_owning_identity(tmp_path: Path) -> None:
+ input_path = tmp_path / "condition.png"
+ target_path = tmp_path / "target.png"
+ _save_image(input_path, (1, 2, 3))
+ _save_image(target_path, (4, 5, 6))
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {
+ "prompt": "content-addressed",
+ "media": [{"type": "image", "path": "condition.png"}],
+ },
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+
+ first_condition_id = compute_offline_condition_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+ first_record_id = compute_offline_record_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+ _save_image(input_path, (7, 8, 9))
+ second_condition_id = compute_offline_condition_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+ second_record_id = compute_offline_record_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+ _save_image(target_path, (10, 11, 12))
+ target_changed_condition_id = compute_offline_condition_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+ target_changed_record_id = compute_offline_record_id(
+ record,
+ index=0,
+ source_name=SOURCE_NAME,
+ )
+
+ assert second_condition_id != first_condition_id
+ assert second_record_id != first_record_id
+ assert target_changed_condition_id == second_condition_id
+ assert target_changed_record_id != second_record_id
+
+
+@pytest.mark.parametrize("changed_arm", ("chosen", "rejected"))
+def test_preference_record_id_tracks_each_output_arm_content(
+ tmp_path: Path,
+ changed_arm: str,
+) -> None:
+ chosen_path = tmp_path / "chosen.png"
+ rejected_path = tmp_path / "rejected.png"
+ _save_image(chosen_path, (1, 2, 3))
+ _save_image(rejected_path, (4, 5, 6))
+ record = _preference_record(tmp_path, ["chosen.png"], ["rejected.png"])
+
+ first = compute_offline_record_id(record, index=0, source_name=SOURCE_NAME)
+ changed_path = chosen_path if changed_arm == "chosen" else rejected_path
+ _save_image(changed_path, (7, 8, 9))
+
+ assert compute_offline_record_id(record, index=0, source_name=SOURCE_NAME) != first
+
+
+def test_dataset_hashes_each_unique_media_path_once_per_construction(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ shared_path = tmp_path / "shared.png"
+ _save_image(shared_path, (1, 2, 3))
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {
+ "prompt": "shared bytes",
+ "media": [
+ {"type": "image", "path": "shared.png"},
+ {"type": "image", "path": "shared.png"},
+ ],
+ },
+ "supervision": {
+ "type": "demonstration",
+ "target": {
+ "media": [
+ {"type": "image", "path": "shared.png"},
+ {"type": "image", "path": "shared.png"},
+ ]
+ },
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+ records = [record, record]
+ condition_cache = _condition_cache(
+ records,
+ [{"prompt_embeds": torch.ones(2)}, {"prompt_embeds": torch.zeros(2)}],
+ )
+ original_hasher = offline_dataset_module._stream_media_content_sha256
+ hashed_paths: List[str] = []
+
+ def counted_hasher(path: str) -> str:
+ hashed_paths.append(path)
+ return original_hasher(path)
+
+ monkeypatch.setattr(
+ offline_dataset_module,
+ "_stream_media_content_sha256",
+ counted_hasher,
+ )
+
+ OfflineDataset(
+ records,
+ condition_cache,
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+ assert hashed_paths == [str(shared_path)]
+
+
+def test_identity_helpers_report_unreadable_media_path(tmp_path: Path) -> None:
+ missing_input = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {
+ "prompt": "missing input",
+ "media": [{"type": "image", "path": "missing-input.png"}],
+ },
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "unused-target.png"}]},
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+ missing_target = _demonstration_record(tmp_path, ["missing-target.png"])
+
+ with pytest.raises(OSError, match=r"hash offline media content.*missing-input\.png"):
+ compute_offline_condition_id(missing_input, index=0, source_name=SOURCE_NAME)
+ with pytest.raises(OSError, match=r"hash offline media content.*missing-target\.png"):
+ compute_offline_record_id(missing_target, index=0, source_name=SOURCE_NAME)
+
+
def test_dataset_snapshots_plain_condition_cache_order(tmp_path: Path) -> None:
for name in ("first.png", "second.png"):
_save_image(tmp_path / name, (1, 2, 3))
@@ -566,11 +730,13 @@ def test_demonstration_collator_stacks_conditions_and_preserves_ragged_media(
{
"prompt_embeds": torch.tensor([1.0, 2.0]),
"ragged": torch.ones(1),
+ "optional_image_latents": None,
"label": "first",
},
{
"prompt_embeds": torch.tensor([3.0, 4.0]),
"ragged": torch.ones(2),
+ "optional_image_latents": torch.ones(2, 3),
"label": "second",
},
]
@@ -594,6 +760,8 @@ def test_demonstration_collator_stacks_conditions_and_preserves_ragged_media(
torch.Size([2]),
]
assert batch.condition["label"] == ["first", "second"]
+ assert batch.condition["optional_image_latents"][0] is None
+ assert batch.condition["optional_image_latents"][1].shape == (2, 3)
assert isinstance(batch.output, DemonstrationOutputBatch)
assert [len(sample_media) for sample_media in batch.output.target_media] == [1, 2]
assert [media.path for media in batch.output.target_media[1]] == [
@@ -793,6 +961,7 @@ def test_default_video_decoder_survives_spawn_worker_pickling(tmp_path: Path) ->
def test_module_level_media_decoder_can_be_injected_explicitly(tmp_path: Path) -> None:
+ (tmp_path / "target.mp4").write_bytes(b"identity-only test payload")
record = normalize_v2_record(
{
"schema_version": 2,
diff --git a/tests/data_utils/test_offline_loader.py b/tests/data_utils/test_offline_loader.py
index 0570a0578..26c1646fe 100644
--- a/tests/data_utils/test_offline_loader.py
+++ b/tests/data_utils/test_offline_loader.py
@@ -454,7 +454,7 @@ def test_builder_rejects_rank_local_empty_loader(tmp_path: Path) -> None:
)
-def test_gradient_accumulation_tail_fails_without_padding_or_implicit_flush(
+def test_gradient_accumulation_tail_fails_without_extra_batches_or_implicit_flush(
tmp_path: Path,
) -> None:
dataset = _offline_dataset(
@@ -474,7 +474,7 @@ def test_gradient_accumulation_tail_fails_without_padding_or_implicit_flush(
)
message = str(exc_info.value)
assert "yields 3 batches" in message
- assert "do not pad" in message
+ assert "does not add batches" in message
assert "implicitly flush" in message
aligned = _build(
diff --git a/tests/data_utils/test_offline_train_data.py b/tests/data_utils/test_offline_train_data.py
index 686942c1d..7c9a3d960 100644
--- a/tests/data_utils/test_offline_train_data.py
+++ b/tests/data_utils/test_offline_train_data.py
@@ -26,6 +26,7 @@
from PIL import Image
from torch.utils.data import ConcatDataset, DistributedSampler
+import flow_factory.data_utils.offline_dataset as offline_dataset_module
import flow_factory.data_utils.offline_train_data as offline_train_data
from flow_factory.contracts import (
BatchCapability,
@@ -224,6 +225,8 @@ def _config(
model_args=SimpleNamespace(
model_type="test-model",
model_name_or_path="test/model",
+ component_load_dtypes=None,
+ frozen_parameters_dtype=None,
),
)
@@ -232,6 +235,35 @@ def _write_image(path: Path, value: int) -> None:
Image.new("RGB", (2, 2), color=(value, value, value)).save(path)
+def test_offline_cache_fingerprint_tracks_precision_aware_model_policies(
+ tmp_path: Path,
+) -> None:
+ """Condition embeddings cannot cross component load or frozen dtype policies."""
+ baseline = _config(tmp_path, [])
+ load_override = _config(tmp_path, [])
+ load_override.model_args.component_load_dtypes = {
+ "text_encoder": torch.float16,
+ "default": None,
+ }
+ reordered_override = _config(tmp_path, [])
+ reordered_override.model_args.component_load_dtypes = {
+ "default": None,
+ "text_encoder": torch.float16,
+ }
+ frozen_override = _config(tmp_path, [])
+ frozen_override.model_args.frozen_parameters_dtype = {
+ "text_encoders": torch.bfloat16,
+ "default": None,
+ }
+
+ baseline_hash = offline_train_data._build_extra_hash_strs(baseline, None)
+ load_hash = offline_train_data._build_extra_hash_strs(load_override, None)
+
+ assert load_hash != baseline_hash
+ assert load_hash == offline_train_data._build_extra_hash_strs(reordered_override, None)
+ assert offline_train_data._build_extra_hash_strs(frozen_override, None) != baseline_hash
+
+
def _demonstration_row(prompt: str, target_path: str, *, metadata: int = 0) -> Dict[str, Any]:
return {
"schema_version": 2,
@@ -312,6 +344,43 @@ def test_builder_returns_detached_input_cache_and_decodes_target_on_demand(
assert preprocessor_ref() is None
+def test_builder_hashes_a_shared_input_and_target_path_once(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Condition projection and full record identity share one build-local memo."""
+ dataset_dir = tmp_path / "shared-identity-media"
+ dataset_dir.mkdir()
+ shared_path = dataset_dir / "shared.png"
+ _write_image(shared_path, 10)
+ row = _demonstration_row("shared media", "shared.png")
+ row["input"]["media"] = [{"type": "image", "path": "shared.png"}]
+ _write_manifest(dataset_dir, [row])
+ original_hasher = offline_dataset_module._stream_media_content_sha256
+ hashed_paths: List[str] = []
+
+ def counted_hasher(path: str) -> str:
+ hashed_paths.append(path)
+ return original_hasher(path)
+
+ monkeypatch.setattr(
+ offline_dataset_module,
+ "_stream_media_content_sha256",
+ counted_hasher,
+ )
+
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("shared-media", dataset_dir, 0)]),
+ _Accelerator(),
+ _OrderedPreprocessor().preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=_ORDERED_IMAGE_CONTRACT,
+ shuffle=False,
+ )
+
+ assert hashed_paths == [str(shared_path)]
+
+
def test_builder_slices_each_source_and_preserves_resolved_source_identity(tmp_path: Path) -> None:
first_dir = tmp_path / "first"
second_dir = tmp_path / "second"
diff --git a/tests/data_utils/test_schema.py b/tests/data_utils/test_schema.py
index f10447cfc..b8c909f2c 100644
--- a/tests/data_utils/test_schema.py
+++ b/tests/data_utils/test_schema.py
@@ -21,30 +21,35 @@
from flow_factory.data_utils.schema import (
DatasetRecordV2,
+ DemonstrationSpec,
DemonstrationSupervision,
PreferenceSupervision,
normalize_v2_record,
)
-def _prompt_only_record(**overrides: Any) -> Dict[str, Any]:
+def _demonstration_record(**overrides: Any) -> Dict[str, Any]:
record: Dict[str, Any] = {
"schema_version": 2,
"input": {"prompt": "A hill at sunset.", "media": []},
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": "target.png"}]},
+ },
}
record.update(overrides)
return record
-def test_prompt_only_boundary_is_strict_and_normalized_record_is_frozen(tmp_path: Path) -> None:
- raw = _prompt_only_record(metadata={"z": [2, 1], "a": {"text": "月亮"}})
+def test_supervised_boundary_is_strict_and_normalized_record_is_frozen(tmp_path: Path) -> None:
+ raw = _demonstration_record(metadata={"z": [2, 1], "a": {"text": "月亮"}})
parsed = DatasetRecordV2.model_validate(raw)
normalized = normalize_v2_record(parsed, dataset_dir=tmp_path / "dataset")
- assert parsed.supervision is None
+ assert isinstance(parsed.supervision, DemonstrationSpec)
assert normalized.model_input.prompt == "A hill at sunset."
assert normalized.model_input.media == ()
- assert normalized.supervision is None
+ assert isinstance(normalized.supervision, DemonstrationSupervision)
assert normalized.metadata_json == '{"a":{"text":"月亮"},"z":[2,1]}'
with pytest.raises(ValidationError):
@@ -53,12 +58,20 @@ def test_prompt_only_boundary_is_strict_and_normalized_record_is_frozen(tmp_path
normalized.metadata_json = "{}"
+def test_supervision_is_required_at_the_public_v2_boundary() -> None:
+ raw = _demonstration_record()
+ raw.pop("supervision")
+
+ with pytest.raises(ValidationError):
+ DatasetRecordV2.model_validate(raw)
+
+
def test_demonstration_normalization_preserves_media_order_and_resolves_paths(
tmp_path: Path,
) -> None:
dataset_dir = tmp_path / "dataset"
absolute_target = tmp_path / "absolute" / "target.png"
- raw = _prompt_only_record(
+ raw = _demonstration_record(
input={
"prompt": "Use the references in order.",
"negative_prompt": "blurry",
@@ -97,7 +110,7 @@ def test_demonstration_normalization_preserves_media_order_and_resolves_paths(
def test_preference_normalization_keeps_both_arms_under_one_input(tmp_path: Path) -> None:
- raw = _prompt_only_record(
+ raw = _demonstration_record(
supervision={
"type": "preference",
"chosen": {
@@ -136,7 +149,7 @@ def test_normalization_expands_dataset_root_and_normalizes_absolute_paths(
) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
absolute_with_parent = str(tmp_path / "absolute" / ".." / "target.png")
- raw = _prompt_only_record(
+ raw = _demonstration_record(
input={
"prompt": "normalize paths",
"media": [
@@ -162,7 +175,7 @@ def test_normalization_expands_dataset_root_and_normalizes_absolute_paths(
],
)
def test_v2_media_rejects_legacy_kind_and_unknown_keys(media: Dict[str, Any]) -> None:
- raw = _prompt_only_record(input={"prompt": "strict", "media": [media]})
+ raw = _demonstration_record(input={"prompt": "strict", "media": [media]})
with pytest.raises(ValidationError):
DatasetRecordV2.model_validate(raw)
@@ -186,12 +199,12 @@ def test_v2_media_rejects_legacy_kind_and_unknown_keys(media: Dict[str, Any]) ->
)
def test_v2_rejects_unknown_keys_at_every_public_level(override: Dict[str, Any]) -> None:
with pytest.raises(ValidationError):
- DatasetRecordV2.model_validate(_prompt_only_record(**override))
+ DatasetRecordV2.model_validate(_demonstration_record(**override))
@pytest.mark.parametrize("path", ["", " "])
def test_media_path_must_be_non_empty(path: str) -> None:
- raw = _prompt_only_record(
+ raw = _demonstration_record(
input={"prompt": "invalid path", "media": [{"type": "image", "path": path}]}
)
@@ -221,14 +234,14 @@ def test_media_path_must_be_non_empty(path: str) -> None:
],
)
def test_media_rates_are_strict_positive_and_type_specific(media: Dict[str, Any]) -> None:
- raw = _prompt_only_record(input={"prompt": "invalid rate", "media": [media]})
+ raw = _demonstration_record(input={"prompt": "invalid rate", "media": [media]})
with pytest.raises(ValidationError):
DatasetRecordV2.model_validate(raw)
def test_video_fps_and_audio_sample_rate_are_optional_source_overrides() -> None:
- raw = _prompt_only_record(
+ raw = _demonstration_record(
input={
"prompt": "source rates",
"media": [
@@ -247,12 +260,12 @@ def test_video_fps_and_audio_sample_rate_are_optional_source_overrides() -> None
@pytest.mark.parametrize("schema_version", [1, "2", True])
def test_schema_version_is_strictly_integer_two(schema_version: Any) -> None:
with pytest.raises(ValidationError):
- DatasetRecordV2.model_validate(_prompt_only_record(schema_version=schema_version))
+ DatasetRecordV2.model_validate(_demonstration_record(schema_version=schema_version))
@pytest.mark.parametrize("supervision_type", ["sft", "offline-dpo", "unknown"])
def test_supervision_uses_semantic_discriminator(supervision_type: str) -> None:
- raw = _prompt_only_record(
+ raw = _demonstration_record(
supervision={
"type": supervision_type,
"target": {"media": [{"type": "image", "path": "target.png"}]},
@@ -288,11 +301,11 @@ def test_supervision_branches_cannot_be_mixed_or_incomplete(
supervision: Dict[str, Any],
) -> None:
with pytest.raises(ValidationError):
- DatasetRecordV2.model_validate(_prompt_only_record(supervision=supervision))
+ DatasetRecordV2.model_validate(_demonstration_record(supervision=supervision))
def test_output_candidate_requires_at_least_one_media_item() -> None:
- raw = _prompt_only_record(supervision={"type": "demonstration", "target": {"media": []}})
+ raw = _demonstration_record(supervision={"type": "demonstration", "target": {"media": []}})
with pytest.raises(ValidationError):
DatasetRecordV2.model_validate(raw)
@@ -301,4 +314,4 @@ def test_output_candidate_requires_at_least_one_media_item() -> None:
@pytest.mark.parametrize("bad_value", [object(), float("nan"), float("inf")])
def test_metadata_accepts_only_finite_json_values(bad_value: Any) -> None:
with pytest.raises(ValidationError):
- DatasetRecordV2.model_validate(_prompt_only_record(metadata={"bad": bad_value}))
+ DatasetRecordV2.model_validate(_demonstration_record(metadata={"bad": bad_value}))
From de8f1875c38bab5f570c007b9ed7ed6dc2b2dc04 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 19:28:10 +0800
Subject: [PATCH 20/76] fix(models): preserve offline output semantics
---
src/flow_factory/models/abc.py | 9 ++
src/flow_factory/models/bagel/bagel.py | 16 +++-
src/flow_factory/models/flux/flux1.py | 2 +
src/flow_factory/models/flux/flux1_kontext.py | 5 +-
src/flow_factory/models/flux/flux2.py | 59 ++++++++----
src/flow_factory/models/flux/flux2_klein.py | 47 +++++++---
.../models/sensenova/sensenova.py | 10 ++
src/flow_factory/models/wan/wan2_t2v.py | 4 +
src/flow_factory/models/z_image/z_image.py | 8 ++
.../trainers/offline/offline_dpo.py | 4 +
src/flow_factory/trainers/offline/sft.py | 1 +
src/flow_factory/utils/image.py | 9 +-
tests/models/test_bagel_output_codec.py | 29 ++++++
.../test_classic_image_output_codecs.py | 13 +++
.../models/test_modern_image_output_codecs.py | 58 ++++++++++++
.../models/test_offline_training_guidance.py | 82 +++++++++++++++++
tests/trainers/test_offline_trainers.py | 91 ++++++++++++++++++-
17 files changed, 405 insertions(+), 42 deletions(-)
create mode 100644 tests/models/test_offline_training_guidance.py
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index be1b1e0e4..41ee508d1 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -24,6 +24,7 @@
from abc import ABC, abstractmethod
from contextlib import ExitStack, contextmanager, nullcontext
from dataclasses import asdict, dataclass, field, fields
+from types import MappingProxyType
from typing import (
Any,
ClassVar,
@@ -237,6 +238,14 @@ class BaseAdapter(ABC):
# while online algorithms may continue to construct and use the adapter.
output_state_codec_unavailable_reason: ClassVar[Optional[str]] = None
flow_velocity_direction: ClassVar[Literal["noise", "data"]] = "noise"
+ # Model conditioning for finite-data velocity matching. These arguments are
+ # deliberately independent of sampling configuration and take precedence over
+ # both training arguments and dataset conditions. Conventional CFG adapters
+ # inherit the neutral scale; adapters with learned guidance embeddings or
+ # additional CFG branches replace the complete immutable mapping.
+ offline_training_forward_overrides: ClassVar[Mapping[str, Any]] = MappingProxyType(
+ {"guidance_scale": 1.0}
+ )
# Resolution-invariant latent axis roles for the model-agnostic latent state
# API (see `latent_geometry.py`). ``None`` means "infer from latent ndim" via
diff --git a/src/flow_factory/models/bagel/bagel.py b/src/flow_factory/models/bagel/bagel.py
index 478a482d9..678ec42bd 100644
--- a/src/flow_factory/models/bagel/bagel.py
+++ b/src/flow_factory/models/bagel/bagel.py
@@ -56,6 +56,7 @@
from collections.abc import Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
+from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union
import numpy as np
@@ -193,6 +194,16 @@ class BagelAdapter(BaseAdapter):
4. CFG uses separate pre-computed KV caches for text-only and image-only conditions.
"""
+ offline_training_forward_overrides = MappingProxyType(
+ {
+ "cfg_text_scale": 1.0,
+ "cfg_img_scale": 1.0,
+ "cfg_interval": (0.0, 1.0),
+ "cfg_renorm_min": 0.0,
+ "cfg_renorm_type": "global",
+ }
+ )
+
# Bagel stores raw, variable-size condition images (no fixed resize) and
# re-encodes them at rollout/training. Persist them via the HF Image feature
# so ragged multi-reference batches serialize; they read back as PIL and are
@@ -475,7 +486,10 @@ def encode_image(
images = [[img] for img in images]
# Convert to RGB
- processed = [standardize_image_batch(img_list, output_type="pt") for img_list in images]
+ processed = [
+ standardize_image_batch(img_list, output_type="pt") if img_list else []
+ for img_list in images
+ ]
return {"condition_images": processed}
def encode_video(self, videos: Any) -> None:
diff --git a/src/flow_factory/models/flux/flux1.py b/src/flow_factory/models/flux/flux1.py
index 80ef1dae1..ad851b0d0 100644
--- a/src/flow_factory/models/flux/flux1.py
+++ b/src/flow_factory/models/flux/flux1.py
@@ -19,6 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
@@ -70,6 +71,7 @@ class Flux1Sample(T2ISample):
class Flux1Adapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
"""Concrete implementation for Flow Matching models (FLUX.1)."""
+ offline_training_forward_overrides = MappingProxyType({"guidance_scale": 3.5})
pipeline_io_contract = image_output_contract(
negative_prompt=NegativePromptPolicy.UNSUPPORTED,
)
diff --git a/src/flow_factory/models/flux/flux1_kontext.py b/src/flow_factory/models/flux/flux1_kontext.py
index d9f9007e4..3d22de7dd 100644
--- a/src/flow_factory/models/flux/flux1_kontext.py
+++ b/src/flow_factory/models/flux/flux1_kontext.py
@@ -19,6 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
@@ -133,6 +134,7 @@ def adjust_image_dimension(
class Flux1KontextAdapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
"""Concrete implementation for Flow Matching models (FLUX.1)."""
+ offline_training_forward_overrides = MappingProxyType({"guidance_scale": 3.5})
pipeline_io_contract = image_output_contract(
negative_prompt=NegativePromptPolicy.UNSUPPORTED,
input_image_min_count=1,
@@ -220,13 +222,12 @@ def _standardize_image_input(
if isinstance(images, Image.Image):
images = [images]
elif is_multi_image_batch(images):
- images = [batch[0] for batch in images]
- # A list of list of images
if not self._has_warned_multi_image and any(len(batch) > 1 for batch in images):
self._has_warned_multi_image = True
logger.warning(
"Multiple condition images are not supported for Flux1-Kontext-dev. Only the first image of each batch will be used."
)
+ images = [batch[0] for batch in images]
images = standardize_image_batch(
images,
diff --git a/src/flow_factory/models/flux/flux2.py b/src/flow_factory/models/flux/flux2.py
index 16b1b6d99..8f065a439 100644
--- a/src/flow_factory/models/flux/flux2.py
+++ b/src/flow_factory/models/flux/flux2.py
@@ -19,6 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
@@ -91,6 +92,7 @@ class Flux2Sample(I2ISample):
class Flux2Adapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
+ offline_training_forward_overrides = MappingProxyType({"guidance_scale": 3.5})
pipeline_io_contract = image_output_contract(
negative_prompt=NegativePromptPolicy.UNSUPPORTED,
input_image_min_count=0,
@@ -228,7 +230,7 @@ def encode_image(
device: Optional[torch.device] = None,
dtype: Optional[torch.dtype] = None,
generator: Optional[torch.Generator] = None,
- ) -> Dict[str, Union[List[List[torch.Tensor]], List[torch.Tensor]]]:
+ ) -> Dict[str, Union[List[List[torch.Tensor]], List[Optional[torch.Tensor]]]]:
"""
Encode input condition image(s) into latent representations using the Flux.2 image encoder.
@@ -246,8 +248,8 @@ def encode_image(
Returns:
Dictionary containing:
- condition_images: List[List[torch.Tensor(3, H, W)]]
- - image_latents: List[torch.Tensor(1, seq_len, C)]
- - image_latent_ids: List[torch.Tensor(1, seq_len)]
+ - image_latents: List[Optional[torch.Tensor(1, seq_len, C)]]
+ - image_latent_ids: List[Optional[torch.Tensor(1, seq_len)]]
"""
device = device or self.pipeline.vae.device
dtype = dtype or self.pipeline.vae.dtype
@@ -257,13 +259,24 @@ def encode_image(
images = [images] if not self._is_multi_images_batch(images) else images
# Standardize each batch to PIL format
- images = [self._standardize_image_input(imgs, output_type="pil") for imgs in images]
+ images = [
+ (
+ []
+ if isinstance(imgs, list) and not imgs
+ else self._standardize_image_input(imgs, output_type="pil")
+ )
+ for imgs in images
+ ]
# Resize all condition images
condition_image_tensors: List[List[torch.Tensor]] = [
- self._resize_condition_images(
- condition_images=imgs,
- condition_image_size=condition_image_size,
+ (
+ self._resize_condition_images(
+ condition_images=imgs,
+ condition_image_size=condition_image_size,
+ )
+ if imgs
+ else []
)
for imgs in images
]
@@ -272,6 +285,10 @@ def encode_image(
image_latents_list = []
image_latent_ids_list = []
for cond_img_tensors in condition_image_tensors:
+ if not cond_img_tensors:
+ image_latents_list.append(None)
+ image_latent_ids_list.append(None)
+ continue
image_latents, image_latent_ids = prepare_flux2_condition_latents(
self,
cond_img_tensors,
@@ -350,13 +367,17 @@ def _is_ragged_multi_image_batch(images: Union[ImageBatch, MultiImageBatch]):
return is_ragged_batch
@staticmethod
- def _is_multi_image_latents(image_latents: Union[torch.Tensor, List[torch.Tensor]]):
+ def _is_multi_image_latents(
+ image_latents: Union[torch.Tensor, List[Optional[torch.Tensor]]],
+ ):
is_ragged_image_latents = (
isinstance(image_latents, list)
and len(image_latents) > 0
- and isinstance(image_latents[0], torch.Tensor)
- and image_latents[0].ndim == 2
- ) or ( # List[torch.Tensor : ndim=2 (seq_len, C)]
+ and all(
+ latent is None or (isinstance(latent, torch.Tensor) and latent.ndim == 2)
+ for latent in image_latents
+ )
+ ) or ( # List[Optional[torch.Tensor : ndim=2 (seq_len, C)]]
isinstance(image_latents, torch.Tensor) and image_latents.ndim == 3
) # torch.Tensor : ndim=3 (B, seq_len, C)
return is_ragged_image_latents
@@ -460,11 +481,7 @@ def preprocess_func(
if isinstance(images, list) and all(
isinstance(img, Image.Image) or img is None for img in images
):
- images = [[img] for img in images]
-
- has_images = any(img is not None for img_list in images for img in img_list)
- else:
- has_images = False
+ images = [[img] if img is not None else [] for img in images]
# 2: Handle caption upsampling
if caption_upsample_temperature is not None:
@@ -488,8 +505,10 @@ def preprocess_func(
text_encoder_out_layers=text_encoder_out_layers,
)
- # 4: Batch encode images if present
- if has_images:
+ # 4: Keep image outputs stable whenever the source batch has an image
+ # field. Empty rows remain explicit slots so Arrow uses one schema across
+ # prompt-only and image-conditioned preprocessing chunks.
+ if images is not None:
image_dict = self.encode_image(
images=images,
condition_image_size=condition_image_size,
@@ -949,8 +968,8 @@ def forward(
prompt_embeds: torch.Tensor,
text_ids: Union[torch.Tensor, List[torch.Tensor]],
# Optional for I2I (can be List for ragged batches)
- image_latents: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
- image_latent_ids: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
+ image_latents: Optional[Union[torch.Tensor, List[Optional[torch.Tensor]]]] = None,
+ image_latent_ids: Optional[Union[torch.Tensor, List[Optional[torch.Tensor]]]] = None,
# Next timestep info
t_next: Optional[torch.Tensor] = None,
next_latents: Optional[torch.Tensor] = None,
diff --git a/src/flow_factory/models/flux/flux2_klein.py b/src/flow_factory/models/flux/flux2_klein.py
index c49dfa343..411bdda36 100644
--- a/src/flow_factory/models/flux/flux2_klein.py
+++ b/src/flow_factory/models/flux/flux2_klein.py
@@ -258,9 +258,9 @@ def encode_image(
images: Union[ImageSingle, ImageBatch, MultiImageBatch],
condition_image_size: Union[int, Tuple[int, int]] = CONDITION_IMAGE_SIZE,
device: Optional[torch.device] = None,
- dtype: Optional[torch.device] = None,
+ dtype: Optional[torch.dtype] = None,
generator: Optional[torch.Generator] = None,
- ) -> Dict[str, Union[List[List[torch.Tensor]], torch.Tensor]]:
+ ) -> Dict[str, Union[List[List[torch.Tensor]], List[Optional[torch.Tensor]]]]:
"""Preprocess the image(s) into latents using the FLUX.2 Klein VAE encoder."""
device = self.pipeline.vae.device if device is None else device
dtype = self.pipeline.vae.dtype if dtype is None else dtype
@@ -268,18 +268,33 @@ def encode_image(
if not self._is_multi_images_batch(images):
images = [images] # Wrap into a batch
- images = [self._standardize_image_input(imgs, output_type="pil") for imgs in images]
+ images = [
+ (
+ []
+ if isinstance(imgs, list) and not imgs
+ else self._standardize_image_input(imgs, output_type="pil")
+ )
+ for imgs in images
+ ]
condition_image_tensors: List[List[torch.Tensor]] = [
- self._resize_condition_images(
- condition_images=imgs,
- condition_image_size=condition_image_size,
+ (
+ self._resize_condition_images(
+ condition_images=imgs,
+ condition_image_size=condition_image_size,
+ )
+ if imgs
+ else []
)
for imgs in images
]
image_latents_list = []
image_latent_ids_list = []
for cond_img_tensors in condition_image_tensors:
+ if not cond_img_tensors:
+ image_latents_list.append(None)
+ image_latent_ids_list.append(None)
+ continue
image_latents, image_latent_ids = prepare_flux2_condition_latents(
self,
cond_img_tensors,
@@ -314,13 +329,17 @@ def _is_ragged_multi_image_batch(images: Union[ImageBatch, MultiImageBatch]):
return isinstance(images, list) and is_multi_image_batch(images)
@staticmethod
- def _is_multi_image_latents(image_latents: Union[torch.Tensor, List[torch.Tensor]]):
+ def _is_multi_image_latents(
+ image_latents: Union[torch.Tensor, List[Optional[torch.Tensor]]],
+ ):
is_ragged_image_latents = (
isinstance(image_latents, list)
and len(image_latents) > 0
- and isinstance(image_latents[0], torch.Tensor)
- and image_latents[0].ndim == 2
- ) or ( # List[torch.Tensor : ndim=2 (seq_len, C)]
+ and all(
+ latent is None or (isinstance(latent, torch.Tensor) and latent.ndim == 2)
+ for latent in image_latents
+ )
+ ) or ( # List[Optional[torch.Tensor : ndim=2 (seq_len, C)]]
isinstance(image_latents, torch.Tensor) and image_latents.ndim == 3
) # torch.Tensor : ndim=3 (B, seq_len, C)
return is_ragged_image_latents
@@ -698,8 +717,8 @@ def inference(
negative_text_ids: Optional[torch.Tensor] = None,
# Encoded images
condition_images: Optional[MultiImageBatch] = None,
- image_latents: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
- image_latent_ids: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
+ image_latents: Optional[Union[torch.Tensor, List[Optional[torch.Tensor]]]] = None,
+ image_latent_ids: Optional[Union[torch.Tensor, List[Optional[torch.Tensor]]]] = None,
# Other arguments
compute_log_prob: bool = False,
extra_call_back_kwargs: List[str] = [],
@@ -954,8 +973,8 @@ def forward(
prompt_embeds: torch.Tensor,
text_ids: Union[torch.Tensor, List[torch.Tensor]],
# Optional for I2I (can be List for ragged batches)
- image_latents: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
- image_latent_ids: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
+ image_latents: Optional[Union[torch.Tensor, List[Optional[torch.Tensor]]]] = None,
+ image_latent_ids: Optional[Union[torch.Tensor, List[Optional[torch.Tensor]]]] = None,
# Optional for CFG
negative_prompt_embeds: Optional[torch.Tensor] = None,
negative_text_ids: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
diff --git a/src/flow_factory/models/sensenova/sensenova.py b/src/flow_factory/models/sensenova/sensenova.py
index 321ffdff5..a401ef5ed 100644
--- a/src/flow_factory/models/sensenova/sensenova.py
+++ b/src/flow_factory/models/sensenova/sensenova.py
@@ -18,6 +18,7 @@
import math
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
@@ -93,6 +94,15 @@ class SenseNovaAdapter(BaseAdapter):
standalone Flow-Factory VAE or text encoder.
"""
+ offline_training_forward_overrides = MappingProxyType(
+ {
+ "guidance_scale": 1.0,
+ "image_guidance_scale": 1.0,
+ "cfg_norm": "none",
+ "cfg_interval": (0.0, 1.0),
+ }
+ )
+
# Reference images have variable spatial sizes/counts and are re-encoded at
# rollout/replay time. Persist them through the HF Image feature as PIL.
python_format_columns: ClassVar[frozenset[str]] = frozenset({"condition_images"})
diff --git a/src/flow_factory/models/wan/wan2_t2v.py b/src/flow_factory/models/wan/wan2_t2v.py
index 1c0db6aae..8039e0124 100644
--- a/src/flow_factory/models/wan/wan2_t2v.py
+++ b/src/flow_factory/models/wan/wan2_t2v.py
@@ -21,6 +21,7 @@
from collections import defaultdict
from dataclasses import dataclass
from numbers import Real
+from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
@@ -58,6 +59,9 @@ class WanT2VSample(T2VSample):
class Wan2_T2V_Adapter(BaseAdapter):
+ offline_training_forward_overrides = MappingProxyType(
+ {"guidance_scale": 1.0, "guidance_scale_2": 1.0}
+ )
# Wan2.2 trains both transformer and transformer_2 but uses only one per
# timestep (boundary_ratio), so under DDP the other's trainable params get no
# gradient in a given step. Ignored under DeepSpeed/FSDP.
diff --git a/src/flow_factory/models/z_image/z_image.py b/src/flow_factory/models/z_image/z_image.py
index 56ef3b52e..22eab95a7 100644
--- a/src/flow_factory/models/z_image/z_image.py
+++ b/src/flow_factory/models/z_image/z_image.py
@@ -19,6 +19,7 @@
import os
from collections import defaultdict
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import torch
@@ -65,6 +66,13 @@ class ZImageSample(T2ISample):
class ZImageAdapter(ConfiguredImageOutputAdapterMixin, BaseAdapter):
"""Adapt Z-Image for online generation and offline image targets."""
+ offline_training_forward_overrides = MappingProxyType(
+ {
+ "guidance_scale": 0.0,
+ "cfg_normalization": False,
+ "cfg_truncation": 1.0,
+ }
+ )
pipeline_io_contract = image_output_contract(
negative_prompt=NegativePromptPolicy.OPTIONAL,
)
diff --git a/src/flow_factory/trainers/offline/offline_dpo.py b/src/flow_factory/trainers/offline/offline_dpo.py
index 34d5f8efe..31d94d011 100644
--- a/src/flow_factory/trainers/offline/offline_dpo.py
+++ b/src/flow_factory/trainers/offline/offline_dpo.py
@@ -101,6 +101,7 @@ def optimize_batch(self, batch: Any) -> None:
chosen_noised.state,
chosen_times,
source="offline DPO policy chosen",
+ **self.adapter.offline_training_forward_overrides,
)
policy_rejected = forward_velocity_state(
self,
@@ -108,6 +109,7 @@ def optimize_batch(self, batch: Any) -> None:
rejected_noised.state,
rejected_times,
source="offline DPO policy rejected",
+ **self.adapter.offline_training_forward_overrides,
)
# A full-parameter snapshot is installed once for both arms.
@@ -119,6 +121,7 @@ def optimize_batch(self, batch: Any) -> None:
chosen_noised.state,
chosen_times,
source="offline DPO reference chosen",
+ **self.adapter.offline_training_forward_overrides,
)
reference_rejected = forward_velocity_state(
self,
@@ -126,6 +129,7 @@ def optimize_batch(self, batch: Any) -> None:
rejected_noised.state,
rejected_times,
source="offline DPO reference rejected",
+ **self.adapter.offline_training_forward_overrides,
)
policy_chosen_loss = flow_matching_per_sample_loss(
diff --git a/src/flow_factory/trainers/offline/sft.py b/src/flow_factory/trainers/offline/sft.py
index 77f7c734d..9e4885fd5 100644
--- a/src/flow_factory/trainers/offline/sft.py
+++ b/src/flow_factory/trainers/offline/sft.py
@@ -93,6 +93,7 @@ def optimize_batch(self, batch: Any) -> None:
noised.state,
times,
source="SFT policy",
+ **self.adapter.offline_training_forward_overrides,
)
time_losses.append(
flow_matching_per_sample_loss(
diff --git a/src/flow_factory/utils/image.py b/src/flow_factory/utils/image.py
index 6794bf755..2ecb50afb 100644
--- a/src/flow_factory/utils/image.py
+++ b/src/flow_factory/utils/image.py
@@ -327,7 +327,14 @@ def is_multi_image_batch(image_batches: Any) -> bool:
): # If None, here will return False
return False
- return all(is_image_batch(batch) for batch in image_batches)
+ # Dataset preprocessing represents an absent optional condition as an
+ # empty per-sample list. It is still part of a MultiImageBatch when sibling
+ # samples contain images (or when an all-empty preprocessing shard belongs
+ # to a dataset that contains images elsewhere).
+ return all(
+ (isinstance(batch, list) and len(batch) == 0) or is_image_batch(batch)
+ for batch in image_batches
+ )
# ----------------------------------- Normalization --------------------------------------
diff --git a/tests/models/test_bagel_output_codec.py b/tests/models/test_bagel_output_codec.py
index ae6c175fb..69031d9a7 100644
--- a/tests/models/test_bagel_output_codec.py
+++ b/tests/models/test_bagel_output_codec.py
@@ -241,6 +241,35 @@ def test_bagel_pipeline_contract_covers_t2i_and_ordered_multi_image_i2i(
assert tuple(item.type for item in contract.output_media.items) == (MediaType.IMAGE,)
+def test_bagel_offline_training_disables_both_cfg_branches(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_cls = _load_bagel_adapter(monkeypatch)
+
+ assert dict(adapter_cls.offline_training_forward_overrides) == {
+ "cfg_text_scale": 1.0,
+ "cfg_img_scale": 1.0,
+ "cfg_interval": (0.0, 1.0),
+ "cfg_renorm_min": 0.0,
+ "cfg_renorm_type": "global",
+ }
+
+
+def test_bagel_condition_encoding_preserves_mixed_t2i_i2i_slots(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """An empty optional-image slot remains aligned with its prompt row."""
+ adapter_cls = _load_bagel_adapter(monkeypatch)
+ adapter = object.__new__(adapter_cls)
+ image = Image.new("RGB", (8, 8))
+
+ encoded = adapter.encode_image([[], [image]])
+
+ assert encoded["condition_images"][0] == []
+ assert len(encoded["condition_images"][1]) == 1
+ assert encoded["condition_images"][1][0].shape == (3, 8, 8)
+
+
def test_bagel_codec_declaration_is_logical_and_does_not_touch_components(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/tests/models/test_classic_image_output_codecs.py b/tests/models/test_classic_image_output_codecs.py
index d68d84b34..1412dde23 100644
--- a/tests/models/test_classic_image_output_codecs.py
+++ b/tests/models/test_classic_image_output_codecs.py
@@ -366,6 +366,19 @@ def test_kontext_condition_encoding_uses_explicit_posterior_argmax() -> None:
assert torch.equal(condition["image_ids"][..., 0], torch.ones(2, 4))
+def test_kontext_flattens_one_condition_image_per_offline_sample() -> None:
+ """GeneralDataset's nested single-image batch remains valid for Kontext."""
+ adapter = object.__new__(Flux1KontextAdapter)
+ adapter._has_warned_multi_image = False
+ first = Image.new("RGB", (WIDTH, HEIGHT), color="red")
+ second = Image.new("RGB", (WIDTH, HEIGHT), color="blue")
+
+ standardized = adapter._standardize_image_input([[first], [second]], output_type="pil")
+
+ assert standardized == [first, second]
+ assert adapter._has_warned_multi_image is False
+
+
def test_z_image_keeps_precision_aware_transformer_loading() -> None:
"""Offline codec support does not weaken the precision branch's model contract."""
assert ZImageAdapter.component_load_dtype_defaults == {"transformer": torch.float32}
diff --git a/tests/models/test_modern_image_output_codecs.py b/tests/models/test_modern_image_output_codecs.py
index 0a7779b9c..67095fb77 100644
--- a/tests/models/test_modern_image_output_codecs.py
+++ b/tests/models/test_modern_image_output_codecs.py
@@ -38,6 +38,7 @@
from flow_factory.models.qwen_image._output import encode_qwen_vae_image
from flow_factory.models.qwen_image.qwen_image import QwenImageAdapter
from flow_factory.models.qwen_image.qwen_image_edit_plus import QwenImageEditPlusAdapter
+from flow_factory.utils.image import is_multi_image_batch
@dataclass(frozen=True)
@@ -279,6 +280,63 @@ def test_flux2_condition_transform_matches_pinned_diffusers() -> None:
assert actual_vae.posteriors[0].mode_calls == 1
+@pytest.mark.parametrize("adapter_cls", [Flux2Adapter, Flux2KleinAdapter])
+def test_flux2_condition_encoding_preserves_mixed_t2i_i2i_slots(
+ adapter_cls: type,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Optional condition images use None latents without shifting batch rows."""
+ adapter = object.__new__(adapter_cls)
+ adapter.pipeline = SimpleNamespace(
+ vae=SimpleNamespace(device=torch.device("cpu"), dtype=torch.float32),
+ image_processor=SimpleNamespace(postprocess=lambda image, output_type: [image.squeeze(0)]),
+ )
+ adapter._standardize_image_input = lambda images, output_type: images
+ adapter._resize_condition_images = lambda condition_images, condition_image_size: [
+ torch.ones(1, 3, 2, 2)
+ ]
+ module = __import__(adapter_cls.__module__, fromlist=["prepare_flux2_condition_latents"])
+ monkeypatch.setattr(
+ module,
+ "prepare_flux2_condition_latents",
+ lambda *args, **kwargs: (torch.ones(1, 4, 2), torch.zeros(1, 4, 4)),
+ )
+ image = Image.new("RGB", (8, 8))
+
+ encoded = adapter.encode_image([[], [image]])
+
+ assert is_multi_image_batch([[], [image]])
+ assert encoded["condition_images"][0] == []
+ assert encoded["image_latents"][0] is None
+ assert encoded["image_latent_ids"][0] is None
+ assert encoded["image_latents"][1].shape == (4, 2)
+ assert encoded["image_latent_ids"][1].shape == (4, 4)
+
+
+@pytest.mark.parametrize("adapter_cls", [Flux2Adapter, Flux2KleinAdapter])
+def test_flux2_preprocess_keeps_optional_image_columns_for_empty_chunk(adapter_cls: type) -> None:
+ """Source-column presence fixes the cache schema even for an all-empty chunk."""
+ adapter = object.__new__(adapter_cls)
+ adapter.encode_prompt = lambda prompt, **kwargs: {"prompt_embeds": torch.ones(len(prompt), 2)}
+ adapter.encode_image = lambda images, **kwargs: {
+ "condition_images": [[] for _ in images],
+ "image_latents": [None for _ in images],
+ "image_latent_ids": [None for _ in images],
+ }
+
+ encoded = adapter.preprocess_func(prompt=["first", "second"], images=[[], []])
+
+ assert set(encoded) == {
+ "prompt_embeds",
+ "condition_images",
+ "image_latents",
+ "image_latent_ids",
+ }
+ assert encoded["condition_images"] == [[], []]
+ assert encoded["image_latents"] == [None, None]
+ assert encoded["image_latent_ids"] == [None, None]
+
+
def test_qwen_target_codec_samples_five_dimensional_latents() -> None:
"""Qwen T2I targets sample the posterior before normalization and 2x2 packing."""
processor = _Processor()
diff --git a/tests/models/test_offline_training_guidance.py b/tests/models/test_offline_training_guidance.py
new file mode 100644
index 000000000..bbf6cccb6
--- /dev/null
+++ b/tests/models/test_offline_training_guidance.py
@@ -0,0 +1,82 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest
+
+from flow_factory.models.abc import BaseAdapter
+from flow_factory.models.flux.flux1 import Flux1Adapter
+from flow_factory.models.flux.flux1_kontext import Flux1KontextAdapter
+from flow_factory.models.flux.flux2 import Flux2Adapter
+from flow_factory.models.flux.flux2_klein import Flux2KleinAdapter
+from flow_factory.models.qwen_image.qwen_image import QwenImageAdapter
+from flow_factory.models.qwen_image.qwen_image_edit_plus import QwenImageEditPlusAdapter
+from flow_factory.models.sensenova.sensenova import SenseNovaAdapter
+from flow_factory.models.stable_diffusion.sd3_5 import SD3_5Adapter
+from flow_factory.models.wan.wan2_t2v import Wan2_T2V_Adapter
+from flow_factory.models.z_image.z_image import ZImageAdapter
+
+
+def test_offline_training_forward_overrides_are_immutable() -> None:
+ with pytest.raises(TypeError):
+ BaseAdapter.offline_training_forward_overrides["guidance_scale"] = 8.0 # type: ignore[index]
+
+
+@pytest.mark.parametrize(
+ "adapter_cls",
+ [
+ BaseAdapter,
+ SD3_5Adapter,
+ Flux2KleinAdapter,
+ QwenImageAdapter,
+ QwenImageEditPlusAdapter,
+ ],
+)
+def test_cfg_adapters_default_to_a_non_composite_offline_velocity(
+ adapter_cls: type[BaseAdapter],
+) -> None:
+ assert dict(adapter_cls.offline_training_forward_overrides) == {"guidance_scale": 1.0}
+
+
+@pytest.mark.parametrize(
+ "adapter_cls",
+ [Flux1Adapter, Flux1KontextAdapter, Flux2Adapter],
+)
+def test_guidance_distilled_adapters_declare_their_official_training_condition(
+ adapter_cls: type[BaseAdapter],
+) -> None:
+ assert dict(adapter_cls.offline_training_forward_overrides) == {"guidance_scale": 3.5}
+
+
+def test_z_image_declares_its_model_specific_cfg_off_value() -> None:
+ assert dict(ZImageAdapter.offline_training_forward_overrides) == {
+ "guidance_scale": 0.0,
+ "cfg_normalization": False,
+ "cfg_truncation": 1.0,
+ }
+
+
+def test_wan_t2v_disables_both_transformer_cfg_scales() -> None:
+ assert dict(Wan2_T2V_Adapter.offline_training_forward_overrides) == {
+ "guidance_scale": 1.0,
+ "guidance_scale_2": 1.0,
+ }
+
+
+def test_sensenova_disables_text_and_image_guidance() -> None:
+ assert dict(SenseNovaAdapter.offline_training_forward_overrides) == {
+ "guidance_scale": 1.0,
+ "image_guidance_scale": 1.0,
+ "cfg_norm": "none",
+ "cfg_interval": (0.0, 1.0),
+ }
diff --git a/tests/trainers/test_offline_trainers.py b/tests/trainers/test_offline_trainers.py
index 3798fe603..547ec44b8 100644
--- a/tests/trainers/test_offline_trainers.py
+++ b/tests/trainers/test_offline_trainers.py
@@ -16,7 +16,7 @@
from collections import defaultdict
from contextlib import contextmanager, nullcontext
-from types import SimpleNamespace
+from types import MappingProxyType, SimpleNamespace
from typing import Any, Iterator, Mapping
import pytest
@@ -49,7 +49,11 @@ class _TrainingArgs(dict):
"""Small mapping/attribute hybrid used by shared forward helpers."""
def __init__(self) -> None:
- super().__init__()
+ super().__init__(
+ guidance_scale=8.0,
+ guidance_scale_2=7.0,
+ image_guidance_scale=6.0,
+ )
self.weighting_scheme = "uniform"
self.num_train_timesteps = 2
self.timestep_range = (0.0, 0.99)
@@ -90,6 +94,13 @@ class _Adapter:
"""Fake one-component codec and flow model with a reference scope."""
trajectory_component_order = ("latent",)
+ offline_training_forward_overrides = MappingProxyType(
+ {
+ "guidance_scale": 2.75,
+ "guidance_scale_2": 1.25,
+ "image_guidance_scale": 1.5,
+ }
+ )
def __init__(self) -> None:
self.policy_weight = torch.nn.Parameter(torch.tensor(0.7))
@@ -98,6 +109,7 @@ def __init__(self) -> None:
self.train_calls = 0
self.encode_calls: list[str] = []
self.forward_events: list[tuple[float, bool, bool]] = []
+ self.forward_override_events: list[tuple[float, bool, dict[str, float]]] = []
self.drawn_noise: list[LatentState] = []
self.reused_noise: list[LatentState] = []
self.ref_scope_enters = 0
@@ -195,12 +207,26 @@ def forward_state(
times: ComponentTimes,
**kwargs: Any,
) -> MultiModalStepOutput:
- del kwargs
arm = batch["arm_token"]
+ resolved_kwargs = {**batch, **kwargs}
coordinate = times.timestep["latent"].float().reshape(arm.shape[0], 1) / 1000.0
self.forward_events.append(
(float(arm[0].item()), self._ref_active, torch.is_grad_enabled())
)
+ self.forward_override_events.append(
+ (
+ float(arm[0].item()),
+ self._ref_active,
+ {
+ key: resolved_kwargs[key]
+ for key in (
+ "guidance_scale",
+ "guidance_scale_2",
+ "image_guidance_scale",
+ )
+ },
+ )
+ )
if self._ref_active:
velocity = 0.2 * coordinate - 0.3 * arm
else:
@@ -254,7 +280,12 @@ def _batch(supervision_type: str, batch_size: int = 2) -> OfflineBatch:
rejected_media=_media("rejected", batch_size),
)
return OfflineBatch(
- condition={"prompt_embeds": torch.ones(batch_size, 2)},
+ condition={
+ "prompt_embeds": torch.ones(batch_size, 2),
+ "guidance_scale": 5.0,
+ "guidance_scale_2": 4.0,
+ "image_guidance_scale": 3.0,
+ },
condition_ids=tuple(f"condition-{index}" for index in range(batch_size)),
record_ids=tuple(f"record-{index}" for index in range(batch_size)),
sources=tuple("source" for _ in range(batch_size)),
@@ -372,6 +403,32 @@ def test_sft_reencodes_targets_and_preserves_optimizer_cadence(
assert len(optimizer_windows[0]["flow_matching_loss"]) == 2
+def test_sft_adapter_overrides_win_over_batch_and_sampling_guidance(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ trainer, adapter, _ = _trainer(SFTTrainer, [True])
+ monkeypatch.setattr(
+ sft_module,
+ "sample_offline_timesteps",
+ lambda *args, **kwargs: torch.tensor([[500.0, 500.0]]),
+ )
+
+ trainer.optimize_batch(_batch("demonstration"))
+
+ assert trainer.training_args["guidance_scale"] == 8.0
+ assert adapter.forward_override_events == [
+ (
+ 0.0,
+ False,
+ {
+ "guidance_scale": 2.75,
+ "guidance_scale_2": 1.25,
+ "image_guidance_scale": 1.5,
+ },
+ )
+ ]
+
+
def test_offline_dpo_shares_schedule_noise_and_reference_scope(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -402,6 +459,32 @@ def test_offline_dpo_shares_schedule_noise_and_reference_scope(
assert all(grad_enabled for _, _, grad_enabled in policy_events)
+def test_offline_dpo_uses_one_adapter_override_mapping_for_policy_and_reference(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ trainer, adapter, _ = _trainer(OfflineDPOTrainer, [True])
+ monkeypatch.setattr(
+ offline_dpo_module,
+ "sample_offline_timesteps",
+ lambda *args, **kwargs: torch.tensor([[500.0, 500.0]]),
+ )
+
+ trainer.optimize_batch(_batch("preference"))
+
+ assert trainer.training_args["guidance_scale"] == 8.0
+ expected = {
+ "guidance_scale": 2.75,
+ "guidance_scale_2": 1.25,
+ "image_guidance_scale": 1.5,
+ }
+ assert adapter.forward_override_events == [
+ (0.0, False, expected),
+ (1.0, False, expected),
+ (0.0, True, expected),
+ (1.0, True, expected),
+ ]
+
+
def test_offline_trainers_reject_the_other_supervision_branch() -> None:
sft, sft_adapter, _ = _trainer(SFTTrainer, [])
dpo, dpo_adapter, _ = _trainer(OfflineDPOTrainer, [])
From 1eacbe9f39aed7a0916725c0b20101766e51f292 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 19:28:54 +0800
Subject: [PATCH 21/76] fix(checkpoint): preserve exact training continuation
---
src/flow_factory/trainers/abc.py | 680 ++++++++++-
.../trainers/common/runtime_identity.py | 1082 +++++++++++++++++
.../trainers/common/runtime_state.py | 349 +++++-
.../distillation/distillation_runtime.py | 221 +++-
.../trainers/distillation/dmd2.py | 2 +-
.../trainers/distillation/opd/trainer.py | 73 +-
src/flow_factory/trainers/distillation/tdm.py | 2 +-
.../trainers/distillation/tdm_r1.py | 7 +-
.../trainers/multirole/__init__.py | 3 +-
.../trainers/multirole/checkpointing.py | 240 +++-
src/flow_factory/trainers/rl/crd.py | 14 +-
src/flow_factory/trainers/rl/dgpo.py | 44 +-
tests/models/test_variant_checkpointing.py | 100 ++
.../test_base_trainer_epoch_contract.py | 54 +-
tests/trainers/test_distillation_metrics.py | 412 ++++++-
tests/trainers/test_dmd2.py | 2 +-
tests/trainers/test_execution_kernel.py | 1 +
.../test_runtime_checkpoint_integration.py | 858 +++++++++++++
tests/trainers/test_runtime_identity.py | 816 +++++++++++++
.../test_runtime_snapshot_lifecycle.py | 255 ++++
20 files changed, 5101 insertions(+), 114 deletions(-)
create mode 100644 src/flow_factory/trainers/common/runtime_identity.py
create mode 100644 tests/trainers/test_runtime_checkpoint_integration.py
create mode 100644 tests/trainers/test_runtime_identity.py
create mode 100644 tests/trainers/test_runtime_snapshot_lifecycle.py
diff --git a/src/flow_factory/trainers/abc.py b/src/flow_factory/trainers/abc.py
index 5d94ef75c..f82186a2d 100644
--- a/src/flow_factory/trainers/abc.py
+++ b/src/flow_factory/trainers/abc.py
@@ -37,7 +37,7 @@
import torch
import torch.nn as nn
from accelerate import Accelerator
-from accelerate.utils import DistributedType, ProjectConfiguration, set_seed
+from accelerate.utils import DistributedType, ProjectConfiguration, gather_object, set_seed
from diffusers.utils.outputs import BaseOutput
from PIL import Image
from torch.utils.data import DataLoader
@@ -77,9 +77,20 @@
filter_kwargs,
json_default,
)
+from ..utils.checkpoint import (
+ HF_PATH_PREFIX,
+ download_hf_checkpoint,
+ parse_hf_checkpoint_path,
+)
from ..utils.dist import gather_aligned_floating_tensors, reduce_loss_info
from ..utils.logger_utils import setup_logger
from ..utils.noise_schedule import TimeSampler
+from .common.runtime_identity import (
+ build_default_data_identity_payload,
+ build_default_execution_identity_payload,
+ build_trainer_runtime_identity,
+)
+from .common.runtime_state import TrainerRuntimeState
from .common.sample_prefetch import iter_prefetched_batches
from .execution import (
AcquisitionDriver,
@@ -87,6 +98,7 @@
build_acquisition_driver,
)
from .multirole import (
+ MULTIROLE_RUNTIME_CHILD_NAME,
MultiRoleBackendValidationMixin,
MultiRoleCheckpointingMixin,
configure_deepspeed_micro_batch_size,
@@ -114,6 +126,10 @@ class BaseTrainer(MultiRoleCheckpointingMixin, MultiRoleBackendValidationMixin,
# MUST override this; leaving it None disables lossy acceleration.
paradigm: ClassVar[Optional[Literal["coupled", "decoupled", "distillation"]]] = None
execution_contract: ClassVar[ExecutionContract] = ONLINE_EXECUTION_CONTRACT
+ runtime_child_names: ClassVar[Tuple[str, ...]] = ()
+
+ _ADAPTER_EMA_RUNTIME_CHILD = "adapter_ema"
+ _ADAPTER_REFERENCE_RUNTIME_CHILD = "adapter_reference"
def __init__(
self,
@@ -140,16 +156,24 @@ def __init__(
self.adapter = adapter
self._validate_adapter_execution_contract()
self.load_coordinator = ModelLoadCoordinator(adapter, accelerator)
- self.progress = TrainingProgress()
+ self.runtime_state = TrainerRuntimeState(child_names=self._declared_runtime_child_names())
+ self._runtime_children_attached = False
+ self._exact_resume_source_checkpoint: Optional[str] = None
+ self._exact_resume_boundary_pending = False
+ self._acquisition_cycle_active = False
+ self._acquisition_cycle_incomplete = False
self.acquisition_driver: AcquisitionDriver = build_acquisition_driver(
type(self).execution_contract
)
self._validate_execution_hooks()
self._initialization()
+ self._realize_runtime_child_declarations()
+ self._initialize_adapter_runtime()
self._initialize_snapshots()
self._register_multirole_checkpointing()
- self.adapter.post_init()
+ self.runtime_state.configure_identity(build_trainer_runtime_identity(self))
+ self._finalize_adapter_runtime()
# Apply persistent stage='both' accelerators last: after prepare, state-resume,
# EMA, and reference-parameter setup, so e.g. torch.compile wraps the final
# weights and keeps state_dict keys / parameter identity stable.
@@ -176,12 +200,45 @@ def cycle_index(self) -> int:
"""Return the completed acquisition-cycle count for this trainer."""
return self._get_progress().cycle_index(type(self).execution_contract.acquisition)
- def _get_progress(self) -> TrainingProgress:
- """Return typed progress for initialized and lightweight test trainers."""
- progress = self.__dict__.get("progress")
+ @property
+ def progress(self) -> TrainingProgress:
+ """Return the single runtime-owned progress value.
+
+ Lightweight structural tests that construct a trainer without running
+ ``BaseTrainer.__init__`` retain a private fallback, but initialized trainers
+ never duplicate counters outside :class:`TrainerRuntimeState`.
+ """
+ runtime_state = self.__dict__.get("runtime_state")
+ if runtime_state is not None:
+ if not isinstance(runtime_state, TrainerRuntimeState):
+ raise TypeError(
+ "expected runtime_state to be TrainerRuntimeState, received "
+ f"{type(runtime_state).__name__}: {runtime_state!r}"
+ )
+ return runtime_state.progress
+ progress = self.__dict__.get("_lightweight_progress")
if progress is None:
progress = TrainingProgress()
- self.__dict__["progress"] = progress
+ self.__dict__["_lightweight_progress"] = progress
+ return progress
+
+ @progress.setter
+ def progress(self, progress: TrainingProgress) -> None:
+ """Replace runtime progress without maintaining a second counter copy."""
+ if not isinstance(progress, TrainingProgress):
+ raise TypeError(
+ "expected trainer progress to be TrainingProgress, received "
+ f"{type(progress).__name__}: {progress!r}"
+ )
+ runtime_state = self.__dict__.get("runtime_state")
+ if runtime_state is None:
+ self.__dict__["_lightweight_progress"] = progress
+ else:
+ runtime_state.progress = progress
+
+ def _get_progress(self) -> TrainingProgress:
+ """Return typed progress for initialized and lightweight test trainers."""
+ progress = self.progress
if not isinstance(progress, TrainingProgress):
raise TypeError(
"expected trainer progress to be TrainingProgress, received "
@@ -243,6 +300,14 @@ def _validate_execution_hooks(self) -> None:
f"dataset trainer {type(self).__name__} must override optimize_batch(batch)"
)
+ def runtime_execution_identity_payload(self) -> Dict[str, Any]:
+ """Return exact-resume objective semantics, extensible by trainers."""
+ return build_default_execution_identity_payload(self)
+
+ def runtime_data_identity_payload(self) -> Dict[str, Any]:
+ """Return exact-resume loader semantics, extensible by trainers."""
+ return build_default_data_identity_payload(self)
+
def _validate_execution_contract(self) -> None:
"""Require trainer runtime and arguments to declare equal semantics."""
type(self).validate_training_arguments_contract(self.training_args)
@@ -303,6 +368,295 @@ def _validate_adapter_execution_contract(self) -> None:
def _initialize_snapshots(self) -> None:
"""Initialize optional trainer-owned parameter snapshots before state resume."""
+ def _declared_runtime_child_names(self) -> Tuple[str, ...]:
+ """Declare every child whose state must participate in exact resume.
+
+ The declaration is configuration-derived and therefore available before
+ heavyweight initialization. Concrete trainers may add class-level names and
+ register matching objects during :meth:`_initialize_snapshots`.
+ """
+ algorithm_names = self._algorithm_runtime_child_names()
+ reserved_names = {
+ self._ADAPTER_EMA_RUNTIME_CHILD,
+ self._ADAPTER_REFERENCE_RUNTIME_CHILD,
+ MULTIROLE_RUNTIME_CHILD_NAME,
+ }
+ collisions = tuple(name for name in algorithm_names if name in reserved_names)
+ if collisions:
+ raise ValueError(
+ "algorithm runtime child names collide with framework-reserved names: "
+ f"collisions={collisions!r}, reserved={tuple(sorted(reserved_names))!r}"
+ )
+
+ names = []
+ if self.training_args.ema_decay > 0:
+ names.append(self._ADAPTER_EMA_RUNTIME_CHILD)
+ if self.training_args.requires_ref_model and self.model_args.finetune_type == "full":
+ names.append(self._ADAPTER_REFERENCE_RUNTIME_CHILD)
+ if len(self._required_trainable_roles()) > 1:
+ names.append(MULTIROLE_RUNTIME_CHILD_NAME)
+ names.extend(algorithm_names)
+ return tuple(names)
+
+ def _algorithm_runtime_child_names(self) -> Tuple[str, ...]:
+ """Return configuration-active trainer-owned checkpoint children.
+
+ Most algorithms declare a fixed class-level tuple. Algorithms whose
+ snapshots are conditional or data-driven may override this hook, while
+ retaining a declaration that is computable before heavyweight setup.
+ """
+ return type(self).runtime_child_names
+
+ def _realize_runtime_child_declarations(self) -> None:
+ """Refresh declarations after algorithms materialize their concrete roles."""
+ child_names = self._declared_runtime_child_names()
+ if child_names == self.runtime_state.child_names:
+ return
+ self.runtime_state = TrainerRuntimeState(
+ progress=self.runtime_state.progress,
+ child_names=child_names,
+ )
+
+ def register_runtime_child(self, name: str, child: Any) -> None:
+ """Register one trainer-owned child declared by ``runtime_child_names``.
+
+ This hook lets an algorithm build a reference/EMA snapshot after distributed
+ preparation while still attaching it only after an exact-resume payload has
+ been fully preflighted and committed.
+
+ Args:
+ name: Configuration-declared runtime child name.
+ child: Object implementing state, validation, and load methods.
+ """
+ declared_names = self._algorithm_runtime_child_names()
+ if name not in declared_names:
+ raise KeyError(
+ f"trainer runtime child {name!r} was not declared for this "
+ f"configuration; expected one of {declared_names!r}"
+ )
+ children = self.__dict__.setdefault("_trainer_runtime_children", {})
+ if name in children:
+ raise RuntimeError(f"trainer runtime child {name!r} is already registered")
+ children[name] = child
+
+ def _register_named_parameter_runtime_child(self, name: str) -> None:
+ """Register one realized adapter named-parameter snapshot for exact resume."""
+ named_parameters = getattr(self.adapter, "_named_parameters", None)
+ if not isinstance(named_parameters, dict):
+ raise TypeError(
+ "adapter named-parameter snapshots must be stored as a dict, received "
+ f"{type(named_parameters).__name__}"
+ )
+ if name not in named_parameters:
+ raise KeyError(
+ f"adapter named-parameter snapshot {name!r} was not initialized; "
+ f"available snapshots={tuple(named_parameters)!r}"
+ )
+ child = getattr(named_parameters[name], "ema_wrapper", None)
+ if child is None:
+ raise TypeError(
+ f"adapter named-parameter snapshot {name!r} has no checkpointable " "ema_wrapper"
+ )
+ self.register_runtime_child(name, child)
+
+ @contextmanager
+ def _suspend_adapter_state_resume(self) -> Iterator[None]:
+ """Let adapter post-init realize children without loading state itself."""
+ resume_path = self.model_args.resume_path
+ resume_type = self.model_args.resume_type
+ self.model_args.resume_path = None
+ self.model_args.resume_type = None
+ try:
+ yield
+ finally:
+ self.model_args.resume_path = resume_path
+ self.model_args.resume_type = resume_type
+
+ def _runtime_checkpoint_children(self) -> Dict[str, Any]:
+ """Return realized children in the immutable declaration order."""
+ children: Dict[str, Any] = {}
+ declared_names = self.runtime_state.child_names
+ if self._ADAPTER_EMA_RUNTIME_CHILD in declared_names:
+ children[self._ADAPTER_EMA_RUNTIME_CHILD] = self.adapter.ema_wrapper
+ if self._ADAPTER_REFERENCE_RUNTIME_CHILD in declared_names:
+ children[self._ADAPTER_REFERENCE_RUNTIME_CHILD] = self.adapter._ref_ema
+ if MULTIROLE_RUNTIME_CHILD_NAME in declared_names:
+ children[MULTIROLE_RUNTIME_CHILD_NAME] = self._multirole_checkpoint_state
+ children.update(self.__dict__.get("_trainer_runtime_children", {}))
+
+ missing = tuple(name for name in declared_names if children.get(name) is None)
+ unexpected = tuple(name for name in children if name not in declared_names)
+ if missing or unexpected:
+ raise RuntimeError(
+ "trainer runtime children do not match their declaration: "
+ f"missing={missing!r}, unexpected={unexpected!r}"
+ )
+ return {name: children[name] for name in declared_names}
+
+ def _attach_runtime_children(self, children: Dict[str, Any]) -> None:
+ """Attach every prevalidated child exactly once after state restoration."""
+ if self._runtime_children_attached:
+ raise RuntimeError("trainer runtime children are already attached")
+ for name in self.runtime_state.child_names:
+ self.runtime_state.attach_child(name, children[name])
+ self._runtime_children_attached = True
+
+ def _validate_runtime_checkpoint_invariants(
+ self,
+ progress: TrainingProgress,
+ child_states: Dict[str, Any],
+ ) -> None:
+ """Validate cross-child counter invariants before prepared-state mutation."""
+ if MULTIROLE_RUNTIME_CHILD_NAME in child_states:
+ self._multirole_checkpoint_state.validate_runtime_progress(
+ progress,
+ child_states[MULTIROLE_RUNTIME_CHILD_NAME],
+ )
+
+ def _validate_distributed_runtime_children(self) -> None:
+ """Reject auxiliary state that lacks a distributed-aware gather contract."""
+ distributed_type = getattr(getattr(self, "accelerator", None), "distributed_type", None)
+ if distributed_type is not DistributedType.FSDP:
+ return
+ unsafe_children = [
+ name
+ for name in (
+ self._ADAPTER_EMA_RUNTIME_CHILD,
+ self._ADAPTER_REFERENCE_RUNTIME_CHILD,
+ )
+ if name in self.runtime_state.child_names
+ ]
+ unsafe_children.extend(
+ name
+ for name in self._algorithm_runtime_child_names()
+ if name in self.runtime_state.child_names
+ )
+ registry = getattr(self.adapter, "component_variant_registry", None)
+ snapshots = getattr(registry, "_snapshots", {})
+ if snapshots:
+ unsafe_children.append("multirole_variant_snapshots")
+ if unsafe_children:
+ raise RuntimeError(
+ "exact state checkpointing under FSDP requires a "
+ "distributed-aware gather/restore implementation for auxiliary tensors; "
+ f"unsupported runtime children={tuple(unsafe_children)!r}. Model-only "
+ "checkpoints remain supported."
+ )
+
+ def _validate_runtime_child_coverage(self) -> None:
+ """Reject adapter snapshots that an exact checkpoint would silently omit."""
+ named_parameters = getattr(self.adapter, "_named_parameters", {})
+ if not named_parameters:
+ return
+ if not isinstance(named_parameters, dict):
+ raise TypeError(
+ "adapter named-parameter snapshots must be stored as a dict, received "
+ f"{type(named_parameters).__name__}"
+ )
+ tracked_children = self._runtime_checkpoint_children()
+ tracked_identities = {id(child) for child in tracked_children.values()}
+ untracked_names = tuple(
+ name
+ for name, info in named_parameters.items()
+ if id(getattr(info, "ema_wrapper", None)) not in tracked_identities
+ )
+ if untracked_names:
+ raise RuntimeError(
+ "exact state checkpointing would omit adapter named-parameter snapshots "
+ f"{untracked_names!r}; declare runtime_child_names and register each wrapper "
+ "during _initialize_snapshots(), or save model weights only"
+ )
+
+ def _initialize_adapter_runtime(self) -> None:
+ """Run adapter post-init while deferring only exact prepared-state loading."""
+ state_resume = bool(self.model_args.resume_path and self.model_args.resume_type == "state")
+ if state_resume:
+ # Reject declaration-known FSDP auxiliary state before allocating late
+ # EMA/reference snapshots. Variant snapshots are checked again after
+ # their algorithm hook materializes them.
+ self._validate_distributed_runtime_children()
+ # BaseAdapter.post_init historically performs load-before-EMA. Temporarily
+ # suppress only that load so the same hook still realizes all late children;
+ # the trainer then owns the preflight/load/commit boundary below.
+ with self._suspend_adapter_state_resume():
+ self.adapter.post_init()
+ else:
+ self.adapter.post_init()
+
+ @staticmethod
+ def _resolve_exact_state_checkpoint_path(path: str) -> str:
+ """Resolve exact-state input without an internal distributed barrier.
+
+ Rank-local resolution failures are synchronized by the caller before any
+ prepared state mutates. The adapter's general resolver intentionally ends in
+ a barrier after an HF download, which is useful for ordinary weight loading
+ but would hide an asymmetric failure from that preflight error gather.
+ """
+ path = os.path.expanduser(path)
+ force_hf = path.startswith(HF_PATH_PREFIX)
+ if not force_hf and os.path.exists(path):
+ return path
+ repo_id, subfolder, revision = parse_hf_checkpoint_path(path)
+ return download_hf_checkpoint(repo_id, subfolder, revision)
+
+ def _finalize_adapter_runtime(self) -> None:
+ """Execute exact-resume preflight/load/commit after all children exist."""
+ state_resume = bool(self.model_args.resume_path and self.model_args.resume_type == "state")
+ if state_resume:
+ children: Dict[str, Any] = {}
+ resume_path = ""
+ preflight_error = None
+ try:
+ children = self._runtime_checkpoint_children()
+ self._validate_distributed_runtime_children()
+ resume_path = self._resolve_exact_state_checkpoint_path(self.model_args.resume_path)
+ self.runtime_state.validate_load(
+ resume_path,
+ children=children,
+ invariant_validator=self._validate_runtime_checkpoint_invariants,
+ expected_process_index=self.accelerator.process_index,
+ expected_device_type=self.accelerator.device.type,
+ )
+ except Exception as error:
+ preflight_error = error
+ self._synchronize_checkpoint_phase_error("resume preflight", preflight_error)
+
+ core_load_error = None
+ try:
+ # The public adapter wrapper adds an unconditional trailing barrier.
+ # Invoke the already-resolved prepared-state primitive directly so a
+ # rank-local exception can reach the error gather below instead of
+ # leaving successful peers blocked at that barrier.
+ self.adapter._load_training_state(resume_path)
+ except Exception as error:
+ core_load_error = error
+ self._synchronize_checkpoint_phase_error(
+ "Accelerator artifact load",
+ core_load_error,
+ )
+ runtime_commit_error = None
+ try:
+ self.runtime_state.commit_validated_load()
+ self._attach_runtime_children(children)
+ except Exception as error:
+ runtime_commit_error = error
+ self._synchronize_checkpoint_phase_error(
+ "runtime child commit",
+ runtime_commit_error,
+ )
+ self._exact_resume_source_checkpoint = self._canonical_checkpoint_path(resume_path)
+ self._exact_resume_boundary_pending = (
+ type(self).execution_contract.acquisition is AcquisitionMode.GENERATION
+ )
+ else:
+ children = self._runtime_checkpoint_children()
+ self._attach_runtime_children(children)
+
+ @staticmethod
+ def _canonical_checkpoint_path(path: str) -> str:
+ """Return a symlink-resolved absolute checkpoint identity."""
+ return os.path.realpath(os.path.abspath(os.path.expanduser(os.fspath(path))))
+
def should_continue_training(self) -> bool:
"""Continue until the active acquisition cycle reaches ``max_epochs``."""
m = self.training_args.max_epochs
@@ -1076,35 +1430,85 @@ def start(self) -> None:
if contract.acquisition is AcquisitionMode.GENERATION:
self._run_periodic_cycle_boundaries()
- driver.run_cycle(self, self._get_progress())
-
- if contract.acquisition is AcquisitionMode.GENERATION:
- self.adapter.ema_step(step=self.cycle_index)
- self._after_optimizer_step()
- self.progress = self._get_progress().advance_acquisition(
- contract.acquisition,
- completed=True,
- )
+ self._acquisition_cycle_active = True
+ self._acquisition_cycle_incomplete = True
+ try:
+ driver.run_cycle(self, self._get_progress())
+ if contract.acquisition is AcquisitionMode.GENERATION:
+ self.adapter.ema_step(step=self.cycle_index)
+ self._after_acquisition_cycle()
+ self.progress = self._get_progress().advance_acquisition(
+ contract.acquisition,
+ completed=True,
+ )
+ self._acquisition_cycle_incomplete = False
+ finally:
+ self._acquisition_cycle_active = False
if contract.acquisition is AcquisitionMode.DATASET:
self._run_periodic_cycle_boundaries()
def _run_periodic_cycle_boundaries(self) -> None:
- """Run save and evaluation actions at the completed-cycle index."""
- if (
+ """Run acquisition-specific save/evaluation ordering at a cycle boundary.
+
+ Online training preserves its pre-rollout save-then-evaluate cadence. Offline
+ evaluation runs first so an exact checkpoint captures the post-evaluation RNG
+ that will precede the next data epoch; model-only saves follow the same visible
+ boundary ordering without claiming exact RNG restoration.
+ """
+ should_save = (
self.log_args.save_freq > 0
and self.cycle_index % self.log_args.save_freq == 0
and self.log_args.save_dir
- ):
+ )
+ save_dir = None
+ save_target = None
+ if should_save:
save_dir = os.path.join(
self.log_args.save_dir,
str(self.log_args.run_name),
"checkpoints",
)
+ save_target = os.path.join(save_dir, f"checkpoint-{self.cycle_index}")
+
+ should_evaluate = (
+ self.eval_args.eval_freq > 0 and self.cycle_index % self.eval_args.eval_freq == 0
+ )
+
+ def save() -> None:
+ if save_dir is None:
+ return
+ if self._should_skip_duplicate_resume_source_checkpoint(save_target):
+ return
self.save_checkpoint(save_dir, epoch=self.cycle_index)
- if self.eval_args.eval_freq > 0 and self.cycle_index % self.eval_args.eval_freq == 0:
- self.evaluate()
+ acquisition = type(self).execution_contract.acquisition
+ if acquisition is AcquisitionMode.DATASET:
+ if should_evaluate:
+ self.evaluate()
+ save()
+ else:
+ save()
+ if should_evaluate:
+ self.evaluate()
+ if getattr(self, "_exact_resume_boundary_pending", False):
+ self._exact_resume_boundary_pending = False
+
+ def _should_skip_duplicate_resume_source_checkpoint(
+ self,
+ save_target: Optional[str],
+ ) -> bool:
+ """Skip only the first online boundary that resolves to its resume source."""
+ if (
+ type(self).execution_contract.acquisition is not AcquisitionMode.GENERATION
+ or not getattr(self, "_exact_resume_boundary_pending", False)
+ or save_target is None
+ ):
+ return False
+ resume_source = getattr(self, "_exact_resume_source_checkpoint", None)
+ if resume_source is None:
+ return False
+ return self._canonical_checkpoint_path(save_target) == resume_source
def set_trajectory_seed(self, seed: int) -> None:
"""Set the adapter seed for one generated acquisition.
@@ -1152,11 +1556,12 @@ def sampling_context(self) -> Iterator[None]:
"""
yield
- def _after_optimizer_step(self) -> None:
- """Update algorithm-owned auxiliary weights after the optimizer step.
+ def _after_acquisition_cycle(self) -> None:
+ """Update algorithm-owned state after one complete acquisition cycle.
- EMA is handled by the loop; this is for extra snapshots an algorithm keeps
- alongside it, such as CRD's old model and sampling model.
+ Generated acquisition calls this once per rollout iteration; dataset
+ acquisition calls it once per complete dataloader epoch. Per-update state
+ belongs in :meth:`_after_gradient_step` instead.
"""
def prepare_feedback(self, samples: List[BaseSample]) -> None:
@@ -1339,9 +1744,9 @@ def _apply_optimizer_step(
def _after_gradient_step(self) -> None:
"""Update per-optimizer-step auxiliary weights before metrics are logged.
- Distinct from :meth:`_after_optimizer_step`, which runs once per epoch;
- this runs on every optimizer step, which is the cadence DGPO's fast
- reference EMA needs.
+ Distinct from :meth:`_after_acquisition_cycle`, which runs after a complete
+ rollout iteration or dataloader epoch; this runs on every optimizer step,
+ which is the cadence DGPO's fast reference EMA needs.
"""
def _velocity_kl(
@@ -1770,7 +2175,203 @@ def evaluate(self) -> None:
self.accelerator.wait_for_everyone()
- def save_checkpoint(self, save_directory: str, epoch: Optional[int] = None):
+ @staticmethod
+ def _state_checkpoint_staging_directory(save_directory: str) -> str:
+ """Return the deterministic sibling used for atomic state publication."""
+ normalized = os.path.normpath(save_directory)
+ parent, basename = os.path.split(normalized)
+ if not basename or basename in (".", ".."):
+ raise ValueError(
+ "state checkpoint destination must name a concrete directory, "
+ f"received {save_directory!r}"
+ )
+ return os.path.join(parent, f".{basename}.flow-factory-staging")
+
+ @staticmethod
+ def _state_checkpoint_publish_claim(save_directory: str) -> str:
+ """Return the sibling lock coordinating local-main publishers."""
+ normalized = os.path.normpath(save_directory)
+ parent, basename = os.path.split(normalized)
+ if not basename or basename in (".", ".."):
+ raise ValueError(
+ "state checkpoint destination must name a concrete directory, "
+ f"received {save_directory!r}"
+ )
+ return os.path.join(parent, f".{basename}.flow-factory-publish-claim")
+
+ def _synchronize_checkpoint_phase_error(
+ self,
+ phase: str,
+ error: Exception | None,
+ ) -> None:
+ """Make every rank leave a failed checkpoint phase without a barrier hang."""
+ process_index = getattr(self.accelerator, "process_index", 0)
+ payload = (
+ None
+ if error is None
+ else {
+ "rank": process_index,
+ "type": type(error).__name__,
+ "message": str(error),
+ }
+ )
+ if getattr(self.accelerator, "num_processes", 1) <= 1:
+ if error is not None:
+ raise error
+ return
+ gathered = gather_object([payload])
+ failures = tuple(item for item in gathered if item is not None)
+ if not failures:
+ return
+ message = f"exact state checkpoint {phase} failed across ranks: {failures!r}"
+ if error is not None:
+ raise RuntimeError(message) from error
+ raise RuntimeError(message)
+
+ def _claim_state_checkpoint_publication(self, claim_path: str) -> bool:
+ """Elect one publisher per visible filesystem using an atomic claim file."""
+ os.makedirs(os.path.dirname(claim_path) or ".", exist_ok=True)
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
+ try:
+ descriptor = os.open(claim_path, flags, 0o600)
+ except FileExistsError:
+ return False
+ try:
+ owner = f"rank={getattr(self.accelerator, 'process_index', 0)}\n".encode("utf-8")
+ os.write(descriptor, owner)
+ finally:
+ os.close(descriptor)
+ return True
+
+ def _save_exact_training_state(self, save_directory: str) -> None:
+ """Write Accelerator artifacts, commit a manifest, then publish atomically."""
+ staging_directory = ""
+ publish_claim = ""
+ preflight_error = None
+ try:
+ if getattr(getattr(self.accelerator, "device", None), "type", None) == "mps":
+ raise RuntimeError(
+ "exact state checkpoints are unsupported on MPS because Accelerate "
+ "does not serialize the MPS RNG state required for exact resume; "
+ "set log.save_model_only=true to save resumable model weights"
+ )
+ if getattr(self, "_acquisition_cycle_active", False) or getattr(
+ self,
+ "_acquisition_cycle_incomplete",
+ False,
+ ):
+ raise RuntimeError(
+ "exact state checkpoints require a complete acquisition boundary; "
+ "the current rollout iteration or data epoch is active or ended partially"
+ )
+ self._validate_distributed_runtime_children()
+ self._validate_runtime_child_coverage()
+ staging_directory = self._state_checkpoint_staging_directory(save_directory)
+ publish_claim = self._state_checkpoint_publish_claim(save_directory)
+ if os.path.lexists(save_directory):
+ raise FileExistsError(
+ "exact state checkpoints are immutable and cannot overwrite an existing "
+ f"destination: {save_directory!r}"
+ )
+ if os.path.lexists(staging_directory):
+ raise FileExistsError(
+ "exact state checkpoint staging already exists, likely from an interrupted "
+ f"save; inspect or remove it explicitly before retrying: {staging_directory!r}"
+ )
+ if os.path.lexists(publish_claim):
+ raise FileExistsError(
+ "exact state checkpoint publication claim already exists, likely from an "
+ f"interrupted save; inspect or remove it explicitly: {publish_claim!r}"
+ )
+ except Exception as error:
+ preflight_error = error
+ self._synchronize_checkpoint_phase_error("preflight", preflight_error)
+ self.accelerator.wait_for_everyone()
+
+ save_on_each_node = self.accelerator.project_configuration.save_on_each_node
+ should_publish = False
+ global_claim_error = None
+ if self.accelerator.is_main_process:
+ try:
+ should_publish = self._claim_state_checkpoint_publication(publish_claim)
+ if not should_publish:
+ raise FileExistsError(
+ "exact state checkpoint publication was claimed by a concurrent "
+ f"writer after preflight: {publish_claim!r}"
+ )
+ except Exception as error:
+ global_claim_error = error
+ self._synchronize_checkpoint_phase_error("global publisher election", global_claim_error)
+
+ local_claim_error = None
+ if (
+ save_on_each_node
+ and self.accelerator.is_local_main_process
+ and not self.accelerator.is_main_process
+ ):
+ try:
+ should_publish = self._claim_state_checkpoint_publication(publish_claim)
+ except Exception as error:
+ local_claim_error = error
+ self._synchronize_checkpoint_phase_error("node publisher election", local_claim_error)
+
+ # On a shared path, the global-main claim is visible to every node and the
+ # other local mains lose election. Disable their generic Accelerate writes
+ # while preserving per-node writes when each node sees its own filesystem.
+ override_node_save = save_on_each_node and self.accelerator.is_local_main_process
+ if override_node_save:
+ self.accelerator.project_configuration.save_on_each_node = should_publish
+ core_save_error = None
+ try:
+ self.adapter.save_checkpoint(
+ save_directory=staging_directory,
+ model_only=False,
+ include_training_roles=True,
+ )
+ except Exception as error:
+ core_save_error = error
+ finally:
+ if override_node_save:
+ self.accelerator.project_configuration.save_on_each_node = save_on_each_node
+ self._synchronize_checkpoint_phase_error("Accelerator artifact save", core_save_error)
+ # FSDP/DeepSpeed may finish rank-local shard writes after the main process
+ # returns from its own save call. Hash only after every rank has arrived.
+ self.accelerator.wait_for_everyone()
+
+ manifest_error = None
+ if should_publish:
+ try:
+ self.runtime_state.prepare_save(staging_directory)
+ except Exception as error:
+ manifest_error = error
+ self._synchronize_checkpoint_phase_error("runtime manifest", manifest_error)
+ self.accelerator.wait_for_everyone()
+
+ publication_error = None
+ if should_publish:
+ try:
+ os.replace(staging_directory, save_directory)
+ except Exception as error:
+ publication_error = error
+ self._synchronize_checkpoint_phase_error("atomic publication", publication_error)
+
+ # Keep every filesystem's claim until every publisher has installed its
+ # final directory. If one node fails, successful nodes retain an explicit
+ # claim beside their final checkpoint instead of looking independently
+ # retryable while another node still has only staging artifacts.
+ claim_cleanup_error = None
+ if should_publish:
+ try:
+ os.unlink(publish_claim)
+ except Exception as error:
+ claim_cleanup_error = error
+ self._synchronize_checkpoint_phase_error(
+ "publication claim cleanup",
+ claim_cleanup_error,
+ )
+ self.accelerator.wait_for_everyone()
+
+ def save_checkpoint(self, save_directory: str, epoch: Optional[int] = None) -> None:
"""Save trainer state to a specific path.
A periodic checkpoint exists to be resumed from, so it carries the
@@ -1781,11 +2382,14 @@ def save_checkpoint(self, save_directory: str, epoch: Optional[int] = None):
if epoch is not None:
save_directory = os.path.join(save_directory, f"checkpoint-{epoch}")
- self.adapter.save_checkpoint(
- save_directory=save_directory,
- model_only=self.log_args.save_model_only,
- include_training_roles=True,
- )
+ if self.log_args.save_model_only:
+ self.adapter.save_checkpoint(
+ save_directory=save_directory,
+ model_only=True,
+ include_training_roles=True,
+ )
+ else:
+ self._save_exact_training_state(save_directory)
self.accelerator.wait_for_everyone()
@@ -1793,8 +2397,14 @@ def load_checkpoint(
self,
path: str,
resume_type: Optional[Literal["lora", "full", "state"]] = None,
- ):
+ ) -> None:
"""Load trainer state from a specific path."""
+ if resume_type == "state":
+ raise RuntimeError(
+ "exact training-state resume must be configured through model.resume_path "
+ "and model.resume_type='state' before trainer construction, so runtime "
+ "identity and child state can be validated before any prepared state mutates"
+ )
self.adapter.load_checkpoint(
path=path,
strict=True,
diff --git a/src/flow_factory/trainers/common/runtime_identity.py b/src/flow_factory/trainers/common/runtime_identity.py
new file mode 100644
index 000000000..47f498ba0
--- /dev/null
+++ b/src/flow_factory/trainers/common/runtime_identity.py
@@ -0,0 +1,1082 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Build deterministic exact-resume identities from realized trainer state."""
+
+import hashlib
+import json
+import math
+import os
+from collections.abc import Mapping, Sequence
+from dataclasses import fields, is_dataclass
+from enum import Enum
+from functools import partial
+from typing import Any
+
+import torch
+from torch.utils.data import ConcatDataset, DataLoader, Subset
+
+_EXECUTION_IDENTITY_HOOK = "runtime_execution_identity_payload"
+_DATA_IDENTITY_HOOK = "runtime_data_identity_payload"
+_OPERATIONAL_TRAINING_FIELDS = frozenset({"max_epochs"})
+_RESUME_MODEL_FIELDS = frozenset({"resume_path", "resume_type"})
+
+
+def build_trainer_runtime_identity(trainer: Any) -> dict[str, Any]:
+ """Describe the realized trainer, model, and optimizer compatibility boundary.
+
+ This function intentionally runs after ``accelerator.prepare`` and variant
+ parameter rebinding. Consequently the schema is derived from the physical
+ parameter roots and optimizer groups that Accelerate will restore, rather than
+ from a pre-prepare configuration approximation.
+
+ Args:
+ trainer: Initialized trainer exposing adapter, optimizer, and accelerator.
+
+ Returns:
+ Strict identity mapping accepted by :class:`TrainerRuntimeState`.
+ """
+ parameter_schema, parameter_keys = _parameter_schema(trainer)
+ optimizer_schema = _optimizer_schema(trainer, parameter_keys)
+ backend_schema = _backend_schema(trainer.accelerator)
+ execution_schema = _trainer_identity_payload(
+ trainer,
+ hook_name=_EXECUTION_IDENTITY_HOOK,
+ default_builder=build_default_execution_identity_payload,
+ )
+ data_schema = _trainer_identity_payload(
+ trainer,
+ hook_name=_DATA_IDENTITY_HOOK,
+ default_builder=build_default_data_identity_payload,
+ )
+ model_args = trainer.model_args
+ training_args = trainer.training_args
+ return {
+ "trainer": _qualified_type_name(type(trainer)),
+ "adapter": _qualified_type_name(type(trainer.adapter)),
+ "algorithm": _require_non_empty_string(
+ getattr(training_args, "trainer_type", None),
+ "training_args.trainer_type",
+ ),
+ "model": (
+ f"{_require_non_empty_string(getattr(model_args, 'model_type', None), 'model.model_type')}"
+ f":{_require_non_empty_string(getattr(model_args, 'model_name_or_path', None), 'model.model_name_or_path')}"
+ ),
+ "finetune_type": _require_non_empty_string(
+ getattr(model_args, "finetune_type", None),
+ "model.finetune_type",
+ ),
+ "optimizer_roles": tuple(trainer._required_trainable_roles()),
+ "parameter_schema_digest": _schema_digest(parameter_schema),
+ "optimizer_schema_digest": _schema_digest(optimizer_schema),
+ "execution_contract_digest": _schema_digest(execution_schema),
+ "data_contract_digest": _schema_digest(data_schema),
+ "distributed_type": backend_schema["distributed_type"],
+ "backend_schema_digest": _schema_digest(backend_schema),
+ "mixed_precision": _require_non_empty_string(
+ getattr(trainer.accelerator, "mixed_precision", None),
+ "accelerator.mixed_precision",
+ ),
+ "gradient_scaler": (
+ "none"
+ if getattr(trainer.accelerator, "scaler", None) is None
+ else _qualified_type_name(type(trainer.accelerator.scaler))
+ ),
+ "world_size": _require_positive_int(
+ getattr(trainer.accelerator, "num_processes", None),
+ "accelerator.num_processes",
+ ),
+ }
+
+
+def build_default_execution_identity_payload(trainer: Any) -> dict[str, Any]:
+ """Return resolved objective and model-forward semantics for exact resume.
+
+ The run budget, logging, checkpoint cadence, and resume location intentionally
+ remain operational controls. Evaluation is identity-locked because the online
+ checkpoint boundary replays evaluation before the next acquisition, and adapter
+ or reward evaluation may consume global device RNG. Everything that can alter a
+ training forward, objective, time sample, reward, optimizer cadence, or replayed
+ evaluation stays locked. Trainers with additional realized semantics may override
+ ``runtime_execution_identity_payload`` and extend this mapping.
+ """
+ training = _export_config(trainer.training_args, "training_args")
+ for field_name in _OPERATIONAL_TRAINING_FIELDS:
+ training.pop(field_name, None)
+
+ model = _export_config(trainer.model_args, "model_args")
+ for field_name in _RESUME_MODEL_FIELDS:
+ model.pop(field_name, None)
+
+ config = trainer.config
+ scheduler = _export_config(config.scheduler_args, "config.scheduler_args")
+ acceleration = _export_config(config.acceleration_args, "config.acceleration_args")
+ rewards = _export_config(trainer.reward_args, "reward_args")
+ evaluation = _evaluation_execution_schema(trainer)
+ optimizer_execution = _resolved_optimizer_execution_schema(trainer)
+ execution_contract = type(trainer).execution_contract
+ acquisition = getattr(execution_contract, "acquisition", None)
+ feedback = getattr(execution_contract, "feedback", None)
+ return {
+ "contract": {
+ "acquisition": _enum_identity_value(
+ acquisition,
+ "execution_contract.acquisition",
+ ),
+ "feedback": _enum_identity_value(
+ feedback,
+ "execution_contract.feedback",
+ ),
+ "paradigm": getattr(type(trainer), "paradigm", None),
+ },
+ "training": training,
+ "scheduler": scheduler,
+ "realized_scheduler_group": _scheduler_group_schema(trainer.adapter),
+ "training_rewards": rewards,
+ "evaluation": evaluation,
+ "acceleration": acceleration,
+ "model_forward": model,
+ "optimizer_execution": optimizer_execution,
+ }
+
+
+def build_default_data_identity_payload(trainer: Any) -> dict[str, Any]:
+ """Return rank-free manifest/fingerprint and loader-order semantics.
+
+ Offline record IDs cover normalized manifest semantics and build-local streaming
+ SHA-256 digests for input and target/chosen/rejected media. Online sources use their
+ resolved Hugging Face preprocessing fingerprints. Ordered training source names are
+ locked, while global numeric source IDs and full name-to-ID registries are excluded
+ because eval-only entries can renumber them without changing training. Ordered
+ realized evaluation loaders are locked separately because online exact resume
+ replays evaluation after the source checkpoint. Sampler rank and mutable epoch/index
+ state are excluded, while seed, shuffle/drop policy, batch geometry, and accumulation
+ cadence remain locked. A trainer with a new loader abstraction can override
+ ``runtime_data_identity_payload`` instead of coupling it to this inspector.
+ """
+ accumulation_steps = getattr(
+ trainer.training_args,
+ "gradient_accumulation_steps",
+ None,
+ )
+ return {
+ "gradient_accumulation_steps": accumulation_steps,
+ "training_loader": _loader_schema(
+ getattr(trainer, "dataloader", None),
+ "dataloader",
+ ),
+ "evaluation_loaders": _evaluation_loader_schema(trainer),
+ }
+
+
+def _evaluation_execution_schema(trainer: Any) -> dict[str, Any]:
+ """Describe evaluation semantics in their realized execution order."""
+ eval_args = _export_config(trainer.eval_args, "eval_args")
+ eval_rewards = _export_config(trainer.eval_reward_args, "eval_reward_args")
+ eval_loaders = _require_named_eval_loaders(trainer)
+ eval_configs = getattr(trainer, "_eval_dataset_configs", None)
+ if not isinstance(eval_configs, Mapping):
+ raise TypeError(
+ "trainer _eval_dataset_configs must be a mapping, received "
+ f"{type(eval_configs).__name__}: {eval_configs!r}"
+ )
+
+ datasets = []
+ for dataset_name in eval_loaders:
+ if dataset_name not in eval_configs:
+ raise KeyError(
+ "trainer evaluation loader has no realized dataset configuration: "
+ f"{dataset_name!r}"
+ )
+ datasets.append(
+ {
+ "name": dataset_name,
+ "configuration": _export_config(
+ eval_configs[dataset_name],
+ f"_eval_dataset_configs[{dataset_name!r}]",
+ ),
+ }
+ )
+ return {
+ "arguments": eval_args,
+ "rewards": eval_rewards,
+ "datasets": datasets,
+ }
+
+
+def _evaluation_loader_schema(trainer: Any) -> list[dict[str, Any]]:
+ """Describe ordered eval loaders without mutable iterator or rank state."""
+ return [
+ {
+ "name": dataset_name,
+ "loader": _loader_schema(
+ loader,
+ f"eval_dataloaders[{dataset_name!r}]",
+ ),
+ }
+ for dataset_name, loader in _require_named_eval_loaders(trainer).items()
+ ]
+
+
+def _require_named_eval_loaders(trainer: Any) -> Mapping[str, Any]:
+ """Return the ordered realized evaluation-loader mapping."""
+ eval_loaders = getattr(trainer, "eval_dataloaders", None)
+ if not isinstance(eval_loaders, Mapping):
+ raise TypeError(
+ "trainer eval_dataloaders must be a mapping, received "
+ f"{type(eval_loaders).__name__}: {eval_loaders!r}"
+ )
+ for dataset_name in eval_loaders:
+ if type(dataset_name) is not str or not dataset_name:
+ raise TypeError(
+ "trainer evaluation-loader names must be non-empty strings, "
+ f"received {dataset_name!r}"
+ )
+ return eval_loaders
+
+
+def _trainer_identity_payload(
+ trainer: Any,
+ *,
+ hook_name: str,
+ default_builder: Any,
+) -> Any:
+ """Call one trainer extension hook and canonicalize its strict mapping."""
+ hook = getattr(trainer, hook_name, None)
+ payload = default_builder(trainer) if hook is None else hook()
+ if not isinstance(payload, Mapping):
+ raise TypeError(
+ f"trainer {hook_name} must return a mapping, received "
+ f"{type(payload).__name__}: {payload!r}"
+ )
+ return _canonical_contract_value(payload, hook_name)
+
+
+def _export_config(value: Any, path: str) -> dict[str, Any]:
+ """Export one resolved config block without accepting object repr fallbacks."""
+ if isinstance(value, Mapping):
+ exported = value
+ else:
+ exporter = getattr(value, "to_dict", None)
+ if not callable(exporter):
+ raise TypeError(
+ f"trainer runtime execution config at {path} must be a mapping or "
+ f"provide to_dict(), received {type(value).__name__}: {value!r}"
+ )
+ exported = exporter()
+ if not isinstance(exported, Mapping):
+ raise TypeError(
+ f"trainer runtime execution config {path}.to_dict() must return a mapping, "
+ f"received {type(exported).__name__}: {exported!r}"
+ )
+ return dict(exported)
+
+
+def _resolved_optimizer_execution_schema(trainer: Any) -> list[dict[str, Any]]:
+ """Describe per-role optimizer semantics absent from parameter groups.
+
+ The realized optimizer groups already lock update-rule fields such as learning
+ rate, moments, and weight decay. Gradient clipping and role update frequency are
+ consumed through ``RoleOptimizerConfig`` instead, so a param-group-only identity
+ would accept a resume that changes the optimization trajectory. Resolve through
+ the trainer hook to include algorithm-provided defaults as well as explicit
+ ``optimizers`` entries.
+ """
+ resolved = []
+ for role_name in trainer._required_trainable_roles():
+ optimizer_args = trainer._optimizer_args_for_role(role_name)
+ resolved.append(
+ {
+ "role_name": role_name,
+ "arguments_type": _qualified_type_name(type(optimizer_args)),
+ "arguments": _export_config(
+ optimizer_args,
+ f"config.optimizer_args[{role_name!r}]",
+ ),
+ }
+ )
+ return resolved
+
+
+def _enum_identity_value(value: Any, path: str) -> str:
+ """Require a concrete enum-valued execution axis."""
+ if not isinstance(value, Enum):
+ raise TypeError(
+ f"trainer runtime identity {path} must be an enum member, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+ return str(value.value)
+
+
+def _scheduler_group_schema(adapter: Any) -> Any:
+ """Describe realized scheduler types and immutable configs in component order."""
+ group = getattr(adapter, "scheduler_group", None)
+ if group is None:
+ return None
+ names = getattr(group, "names", None)
+ if isinstance(names, (str, bytes)):
+ raise TypeError("adapter.scheduler_group.names must be a sequence, not a string")
+ try:
+ names = tuple(names)
+ except TypeError as error:
+ raise TypeError("adapter.scheduler_group.names must be a sequence") from error
+ if not names:
+ raise ValueError("adapter.scheduler_group.names cannot be empty")
+ schedulers = []
+ for name in names:
+ if type(name) is not str or not name:
+ raise TypeError(
+ "adapter.scheduler_group component names must be non-empty strings, "
+ f"received {name!r}"
+ )
+ scheduler = group[name]
+ scheduler_config = getattr(scheduler, "config", None)
+ if scheduler_config is not None:
+ scheduler_config = _export_config(
+ scheduler_config,
+ f"adapter.scheduler_group[{name!r}].config",
+ )
+ schedulers.append(
+ {
+ "name": name,
+ "type": _qualified_type_name(type(scheduler)),
+ "dynamics_type": getattr(scheduler, "dynamics_type", None),
+ "config": scheduler_config,
+ }
+ )
+ return {
+ "primary_name": _require_non_empty_string(
+ getattr(group, "primary_name", None),
+ "adapter.scheduler_group.primary_name",
+ ),
+ "schedulers": schedulers,
+ }
+
+
+def _loader_schema(loader: Any, path: str) -> Any:
+ """Describe one framework train loader without mutable iterator state."""
+ if loader is None:
+ return None
+ loaders_by_source = getattr(loader, "_loaders_by_source", None)
+ source_scheduler = getattr(loader, "_scheduler", None)
+ if isinstance(loaders_by_source, Mapping) and source_scheduler is not None:
+ sources = []
+ for source_name, source_loader in loaders_by_source.items():
+ if type(source_name) is not str or not source_name:
+ raise TypeError(
+ f"multi-source loader name at {path} must be a non-empty str, "
+ f"received {source_name!r}"
+ )
+ sources.append(
+ {
+ "name": source_name,
+ "loader": _loader_schema(
+ source_loader,
+ f"{path}.sources[{source_name!r}]",
+ ),
+ }
+ )
+ return {
+ "type": _qualified_type_name(type(loader)),
+ "batch_size": getattr(loader, "_batch_size", None),
+ "length": len(loader),
+ "sources": sources,
+ "source_schedule": {
+ "counts": getattr(source_scheduler, "_counts", None),
+ "seed": getattr(source_scheduler, "_seed", None),
+ },
+ }
+ if not isinstance(loader, DataLoader):
+ raise TypeError(
+ f"unsupported train loader at {path}: {type(loader).__name__}; "
+ f"override {_DATA_IDENTITY_HOOK}() for a custom acquisition loader"
+ )
+ return {
+ "type": _qualified_type_name(type(loader)),
+ "length": _loader_length(loader, path),
+ "dataset": _dataset_schema(loader.dataset, f"{path}.dataset"),
+ "sampler": _sampler_schema(loader.sampler, f"{path}.sampler"),
+ "batch_sampler": _sampler_schema(
+ loader.batch_sampler,
+ f"{path}.batch_sampler",
+ ),
+ "batch_size": loader.batch_size,
+ "drop_last": loader.drop_last,
+ "num_workers": loader.num_workers,
+ "persistent_workers": loader.persistent_workers,
+ "prefetch_factor": loader.prefetch_factor,
+ "pin_memory": loader.pin_memory,
+ "pin_memory_device": getattr(loader, "pin_memory_device", ""),
+ "timeout": loader.timeout,
+ "in_order": getattr(loader, "in_order", True),
+ "collate": _callable_identity_schema(loader.collate_fn, f"{path}.collate_fn"),
+ "worker_init": _callable_identity_schema(
+ loader.worker_init_fn,
+ f"{path}.worker_init_fn",
+ ),
+ }
+
+
+def _loader_length(loader: DataLoader, path: str) -> int:
+ """Return finite batch geometry for finite or epoch-bounded samplers."""
+ try:
+ length = len(loader)
+ except TypeError:
+ length = getattr(loader.batch_sampler, "num_batches_per_epoch", None)
+ if type(length) is not int or length < 1:
+ raise TypeError(
+ f"train loader length at {path} must be a positive int or expose "
+ f"batch_sampler.num_batches_per_epoch, received {length!r}"
+ )
+ return length
+
+
+def _dataset_schema(dataset: Any, path: str) -> dict[str, Any]:
+ """Describe ordered dataset provenance without decoding large media files."""
+ if isinstance(dataset, ConcatDataset):
+ return {
+ "type": _qualified_type_name(type(dataset)),
+ "length": _dataset_length(dataset, path),
+ "sources": [
+ _dataset_schema(source, f"{path}.sources[{index}]")
+ for index, source in enumerate(dataset.datasets)
+ ],
+ }
+ if isinstance(dataset, Subset):
+ indices = tuple(dataset.indices)
+ return {
+ "type": _qualified_type_name(type(dataset)),
+ "length": len(indices),
+ "indices_digest": _schema_digest(indices),
+ "dataset": _dataset_schema(dataset.dataset, f"{path}.dataset"),
+ }
+
+ record_ids = getattr(dataset, "_record_ids", None)
+ condition_ids = getattr(dataset, "_condition_ids", None)
+ if record_ids is not None or condition_ids is not None:
+ if isinstance(record_ids, (str, bytes)) or isinstance(condition_ids, (str, bytes)):
+ raise TypeError(f"offline dataset IDs at {path} must be ordered sequences")
+ try:
+ record_ids = tuple(record_ids)
+ condition_ids = tuple(condition_ids)
+ except TypeError as error:
+ raise TypeError(f"offline dataset IDs at {path} must be ordered sequences") from error
+ if len(record_ids) != len(condition_ids) or len(record_ids) != _dataset_length(
+ dataset,
+ path,
+ ):
+ raise ValueError(
+ f"offline dataset ID cardinality mismatch at {path}: "
+ f"records={len(record_ids)}, conditions={len(condition_ids)}, "
+ f"dataset={len(dataset)}"
+ )
+ for identifier_name, identifiers in (
+ ("record", record_ids),
+ ("condition", condition_ids),
+ ):
+ invalid = tuple(
+ identifier
+ for identifier in identifiers
+ if type(identifier) is not str or not identifier
+ )
+ if invalid:
+ raise TypeError(
+ f"offline {identifier_name} IDs at {path} must be non-empty strings, "
+ f"received {invalid!r}"
+ )
+ return {
+ "type": _qualified_type_name(type(dataset)),
+ "length": len(record_ids),
+ "source_name": _require_non_empty_string(
+ getattr(dataset, "source_name", None),
+ f"{path}.source_name",
+ ),
+ "supervision_type": _require_non_empty_string(
+ getattr(dataset, "supervision_type", None),
+ f"{path}.supervision_type",
+ ),
+ "record_ids_digest": _schema_digest(record_ids),
+ "condition_ids_digest": _schema_digest(condition_ids),
+ "condition_cache": _fingerprint_schema(
+ getattr(dataset, "_condition_cache", None),
+ f"{path}.condition_cache",
+ ),
+ }
+
+ processed_dataset = getattr(dataset, "processed_dataset", None)
+ if processed_dataset is not None:
+ return {
+ "type": _qualified_type_name(type(dataset)),
+ "length": _dataset_length(dataset, path),
+ "processed": _fingerprint_schema(
+ processed_dataset,
+ f"{path}.processed_dataset",
+ ),
+ }
+ if getattr(dataset, "_fingerprint", None) is not None:
+ return _fingerprint_schema(dataset, path)
+ raise TypeError(
+ f"unsupported train dataset at {path}: {type(dataset).__name__}; "
+ f"override {_DATA_IDENTITY_HOOK}() for a custom dataset contract"
+ )
+
+
+def _fingerprint_schema(dataset: Any, path: str) -> dict[str, Any]:
+ """Require the stable cache/source fingerprint already owned by the dataset."""
+ if dataset is None:
+ raise TypeError(f"dataset fingerprint source at {path} cannot be None")
+ fingerprint = getattr(dataset, "_fingerprint", None)
+ if type(fingerprint) is not str or not fingerprint:
+ raise TypeError(
+ f"dataset at {path} must expose a non-empty _fingerprint, received "
+ f"{type(fingerprint).__name__}: {fingerprint!r}"
+ )
+ return {
+ "type": _qualified_type_name(type(dataset)),
+ "length": _dataset_length(dataset, path),
+ "fingerprint": fingerprint,
+ }
+
+
+def _dataset_length(dataset: Any, path: str) -> int:
+ """Require a finite non-negative dataset cardinality."""
+ try:
+ length = len(dataset)
+ except TypeError as error:
+ raise TypeError(f"train dataset at {path} must have a finite length") from error
+ if type(length) is not int or length < 0:
+ raise TypeError(
+ f"train dataset length at {path} must be a non-negative int, received {length!r}"
+ )
+ return length
+
+
+def _sampler_schema(sampler: Any, path: str) -> Any:
+ """Describe rank-free sampler order and batch geometry."""
+ if sampler is None:
+ return None
+ module = type(sampler).__module__
+ if not (
+ module.startswith("torch.utils.data")
+ or module.startswith("accelerate.data_loader")
+ or module.startswith("flow_factory.data_utils.sampler")
+ ):
+ raise TypeError(
+ f"unsupported sampler at {path}: {_qualified_type_name(type(sampler))}; "
+ f"override {_DATA_IDENTITY_HOOK}() for custom sampler semantics"
+ )
+ schema: dict[str, Any] = {"type": _qualified_type_name(type(sampler))}
+ for field_name in (
+ "batch_size",
+ "drop_last",
+ "shuffle",
+ "seed",
+ "num_replicas",
+ "num_processes",
+ "num_samples",
+ "total_size",
+ "replacement",
+ "k",
+ "m",
+ "sample_num_per_iteration",
+ "groups_per_rank",
+ "copies_per_rank",
+ "num_batches_per_epoch",
+ "split_batches",
+ "even_batches",
+ ):
+ if hasattr(sampler, field_name):
+ schema[field_name] = getattr(sampler, field_name)
+ nested_sampler = getattr(sampler, "sampler", None)
+ if nested_sampler is not None and nested_sampler is not sampler:
+ schema["sampler"] = _sampler_schema(nested_sampler, f"{path}.sampler")
+ nested_batch_sampler = getattr(sampler, "batch_sampler", None)
+ if nested_batch_sampler is not None and nested_batch_sampler is not sampler:
+ schema["batch_sampler"] = _sampler_schema(
+ nested_batch_sampler,
+ f"{path}.batch_sampler",
+ )
+ generator = getattr(sampler, "generator", None)
+ if generator is not None:
+ if not isinstance(generator, torch.Generator):
+ raise TypeError(
+ f"sampler generator at {path} must be torch.Generator or None, "
+ f"received {type(generator).__name__}"
+ )
+ schema["generator_initial_seed"] = generator.initial_seed()
+ # ``rank`` and mutable ``epoch``/iterator offsets are intentionally absent.
+ return schema
+
+
+def _callable_identity_schema(value: Any, path: str) -> Any:
+ """Describe a loader callable without process-local object identity."""
+ if value is None:
+ return None
+ if is_dataclass(value) and not isinstance(value, type):
+ return {
+ "type": _qualified_type_name(type(value)),
+ "state": {
+ field.name: _canonical_contract_value(
+ getattr(value, field.name),
+ f"{path}.{field.name}",
+ )
+ for field in fields(value)
+ },
+ }
+ module = getattr(value, "__module__", None)
+ qualname = getattr(value, "__qualname__", None)
+ if type(module) is str and type(qualname) is str:
+ return {"callable": f"{module}.{qualname}"}
+ if callable(value):
+ return {"type": _qualified_type_name(type(value))}
+ raise TypeError(
+ f"loader callable at {path} must be callable or None, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _canonical_contract_value(value: Any, path: str) -> Any:
+ """Convert execution/data hook output into strict deterministic JSON values."""
+ if value is None or type(value) in (bool, int, str):
+ return value
+ if type(value) is float:
+ if not math.isfinite(value):
+ raise ValueError(f"runtime contract value at {path} must be finite")
+ return value
+ if isinstance(value, Enum):
+ if type(value.value) in (bool, int, float, str):
+ return value.value
+ return f"{_qualified_type_name(type(value))}.{value.name}"
+ if isinstance(value, torch.dtype):
+ return str(value)
+ if isinstance(value, torch.device):
+ return str(value)
+ if isinstance(value, os.PathLike):
+ return os.fspath(value)
+ if is_dataclass(value) and not isinstance(value, type):
+ return {
+ field.name: _canonical_contract_value(
+ getattr(value, field.name),
+ f"{path}.{field.name}",
+ )
+ for field in fields(value)
+ }
+ if isinstance(value, Mapping):
+ result = {}
+ for key in value:
+ if type(key) is not str:
+ raise TypeError(
+ f"runtime contract mapping key at {path} must be str, "
+ f"received {type(key).__name__}: {key!r}"
+ )
+ for key in sorted(value):
+ result[key] = _canonical_contract_value(value[key], f"{path}.{key}")
+ return result
+ if isinstance(value, (set, frozenset)):
+ items = [_canonical_contract_value(item, f"{path}[set]") for item in value]
+ return {
+ "set": sorted(
+ items,
+ key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")),
+ )
+ }
+ if isinstance(value, (tuple, list)):
+ return [
+ _canonical_contract_value(item, f"{path}[{index}]") for index, item in enumerate(value)
+ ]
+ if isinstance(value, type):
+ return {"type": _qualified_type_name(value)}
+ if callable(value):
+ return _callable_backend_schema(value, path=path)
+ raise TypeError(
+ f"unsupported runtime contract value at {path}: " f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _backend_schema(accelerator: Any) -> dict[str, Any]:
+ """Return the prepared-state backend plan that controls checkpoint layout."""
+ distributed_type = _canonical_backend_value(
+ getattr(accelerator, "distributed_type", None),
+ "accelerator.distributed_type",
+ )
+ if type(distributed_type) is not str or not distributed_type:
+ raise TypeError(
+ "trainer runtime identity accelerator.distributed_type must resolve to a "
+ f"non-empty str, received {distributed_type!r}"
+ )
+ state = getattr(accelerator, "state", None)
+ fsdp_plugin = getattr(state, "fsdp_plugin", None)
+ deepspeed_plugin = getattr(state, "deepspeed_plugin", None)
+ fsdp_schema = None
+ if fsdp_plugin is not None:
+ fsdp_schema = {
+ name: _canonical_backend_value(
+ getattr(fsdp_plugin, name, None),
+ f"accelerator.fsdp_plugin.{name}",
+ )
+ for name in (
+ "fsdp_version",
+ "state_dict_type",
+ "state_dict_config",
+ "optim_state_dict_config",
+ "sharding_strategy",
+ "reshard_after_forward",
+ "use_orig_params",
+ "cpu_offload",
+ "mixed_precision_policy",
+ "backward_prefetch",
+ "forward_prefetch",
+ "auto_wrap_policy",
+ "transformer_cls_names_to_wrap",
+ "min_num_params",
+ "limit_all_gathers",
+ "sync_module_states",
+ "cpu_ram_efficient_loading",
+ "activation_checkpointing",
+ )
+ }
+ deepspeed_schema = None
+ if deepspeed_plugin is not None:
+ deepspeed_config = getattr(deepspeed_plugin, "deepspeed_config", None)
+ zero_optimization = (
+ deepspeed_config.get("zero_optimization")
+ if isinstance(deepspeed_config, Mapping)
+ else None
+ )
+ deepspeed_schema = {
+ "zero_stage": _canonical_backend_value(
+ getattr(deepspeed_plugin, "zero_stage", None),
+ "accelerator.deepspeed_plugin.zero_stage",
+ ),
+ "config": _canonical_backend_value(
+ deepspeed_config,
+ "accelerator.deepspeed_plugin.deepspeed_config",
+ ),
+ "gradient_accumulation_steps": _canonical_backend_value(
+ getattr(deepspeed_plugin, "gradient_accumulation_steps", None),
+ "accelerator.deepspeed_plugin.gradient_accumulation_steps",
+ ),
+ "gradient_clipping": _canonical_backend_value(
+ getattr(deepspeed_plugin, "gradient_clipping", None),
+ "accelerator.deepspeed_plugin.gradient_clipping",
+ ),
+ "is_train_batch_min": _canonical_backend_value(
+ getattr(deepspeed_plugin, "is_train_batch_min", None),
+ "accelerator.deepspeed_plugin.is_train_batch_min",
+ ),
+ "zero_optimization": _canonical_backend_value(
+ zero_optimization,
+ "accelerator.deepspeed_plugin.zero_optimization",
+ ),
+ }
+ return {
+ "distributed_type": distributed_type,
+ "gradient_accumulation_steps": _canonical_backend_value(
+ getattr(accelerator, "gradient_accumulation_steps", None),
+ "accelerator.gradient_accumulation_steps",
+ ),
+ "fsdp": fsdp_schema,
+ "deepspeed": deepspeed_schema,
+ }
+
+
+def _canonical_backend_value(value: Any, path: str) -> Any:
+ """Convert backend plugin settings into deterministic JSON-compatible values."""
+ if value is None or type(value) in (bool, int, str):
+ return value
+ if type(value) is float:
+ if math.isnan(value):
+ raise ValueError(f"backend schema value at {path} cannot be NaN")
+ if math.isinf(value):
+ return {"float": "+infinity" if value > 0 else "-infinity"}
+ return value
+ if isinstance(value, Enum):
+ if type(value.value) is str:
+ return value.value
+ return f"{_qualified_type_name(type(value))}.{value.name}"
+ if isinstance(value, torch.dtype):
+ return str(value)
+ if isinstance(value, torch.device):
+ return str(value)
+ if isinstance(value, partial):
+ return {
+ "partial": _callable_backend_schema(value.func, path=f"{path}.func"),
+ "args": [
+ _canonical_backend_value(item, f"{path}.args[{index}]")
+ for index, item in enumerate(value.args)
+ ],
+ "keywords": _canonical_backend_value(
+ value.keywords or {},
+ f"{path}.keywords",
+ ),
+ }
+ if isinstance(value, type):
+ return {"type": _qualified_type_name(value)}
+ if is_dataclass(value) and not isinstance(value, type):
+ return {
+ field.name: _canonical_backend_value(
+ getattr(value, field.name),
+ f"{path}.{field.name}",
+ )
+ for field in fields(value)
+ }
+ if isinstance(value, Mapping):
+ result = {}
+ for key in value:
+ if type(key) is not str:
+ raise TypeError(
+ f"backend schema mapping key at {path} must be str, "
+ f"received {type(key).__name__}: {key!r}"
+ )
+ for key in sorted(value):
+ result[key] = _canonical_backend_value(value[key], f"{path}.{key}")
+ return result
+ if isinstance(value, (set, frozenset)):
+ items = [_canonical_backend_value(item, f"{path}[set]") for item in value]
+ return {
+ "set": sorted(
+ items,
+ key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")),
+ )
+ }
+ if isinstance(value, (tuple, list)):
+ return [
+ _canonical_backend_value(item, f"{path}[{index}]") for index, item in enumerate(value)
+ ]
+ if callable(value):
+ return _callable_backend_schema(value, path=path)
+ raise TypeError(
+ f"unsupported backend schema value at {path}: " f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _callable_backend_schema(value: Any, *, path: str) -> dict[str, Any]:
+ """Describe a wrap-policy callable and its bound configuration."""
+ module = getattr(value, "__module__", None)
+ qualname = getattr(value, "__qualname__", None)
+ if type(module) is not str or type(qualname) is not str:
+ raise TypeError(
+ f"backend schema callable at {path} must expose module and qualname, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ schema: dict[str, Any] = {"callable": f"{module}.{qualname}"}
+ defaults = getattr(value, "__defaults__", None)
+ if defaults:
+ schema["defaults"] = _canonical_backend_value(defaults, f"{path}.defaults")
+ keyword_defaults = getattr(value, "__kwdefaults__", None)
+ if keyword_defaults:
+ schema["keyword_defaults"] = _canonical_backend_value(
+ keyword_defaults,
+ f"{path}.keyword_defaults",
+ )
+ closure = getattr(value, "__closure__", None)
+ if closure:
+ schema["closure"] = [
+ _canonical_backend_value(cell.cell_contents, f"{path}.closure[{index}]")
+ for index, cell in enumerate(closure)
+ ]
+ return schema
+
+
+def _parameter_schema(trainer: Any) -> tuple[list[dict[str, Any]], dict[int, str]]:
+ """Return stable variant-owned parameter records and identity lookup."""
+ registry = trainer.adapter.component_variant_registry
+ schema: list[dict[str, Any]] = []
+ parameter_keys: dict[int, str] = {}
+ stable_keys: set[str] = set()
+ for role_name in trainer._required_trainable_roles():
+ for record in registry.parameter_records(role_name):
+ parameter = record.parameter
+ key = f"{role_name}/{record.component_name}/{record.parameter_name}"
+ if id(parameter) in parameter_keys:
+ raise ValueError(
+ "trainer runtime parameter schema contains duplicate parameter identity: "
+ f"{parameter_keys[id(parameter)]!r} and {key!r}"
+ )
+ if key in stable_keys:
+ raise ValueError(f"trainer runtime parameter schema contains duplicate key {key!r}")
+ parameter_keys[id(parameter)] = key
+ stable_keys.add(key)
+ schema.append(
+ {
+ "key": key,
+ "shape": list(parameter.shape),
+ "dtype": str(parameter.dtype),
+ "requires_grad": bool(parameter.requires_grad),
+ }
+ )
+ if not schema:
+ raise RuntimeError("trainer runtime identity requires at least one parameter record")
+ return schema, parameter_keys
+
+
+def _optimizer_schema(trainer: Any, parameter_keys: Mapping[int, str]) -> dict[str, Any]:
+ """Return ordered optimizer groups linked to the stable parameter schema."""
+ optimizer = trainer.optimizer
+ groups = []
+ consumed_parameters: set[int] = set()
+ for group_index, group in enumerate(optimizer.param_groups):
+ raw_parameters = group.get("params")
+ if not isinstance(raw_parameters, Sequence):
+ raise TypeError(
+ f"optimizer group {group_index} params must be a sequence, "
+ f"received {type(raw_parameters).__name__}: {raw_parameters!r}"
+ )
+ group_parameters = []
+ for parameter_index, parameter in enumerate(raw_parameters):
+ key = parameter_keys.get(id(parameter))
+ if key is None:
+ raise ValueError(
+ "optimizer schema contains a parameter not owned by the rebound "
+ f"variant registry at group {group_index}, index {parameter_index}"
+ )
+ if id(parameter) in consumed_parameters:
+ raise ValueError(f"optimizer schema references parameter {key!r} more than once")
+ consumed_parameters.add(id(parameter))
+ group_parameters.append(key)
+ settings = {}
+ for key, value in group.items():
+ if key == "params":
+ continue
+ if type(key) is not str or not key:
+ raise TypeError(
+ f"optimizer group {group_index} setting key must be a non-empty str, "
+ f"received {type(key).__name__}: {key!r}"
+ )
+ settings[key] = _canonical_optimizer_value(
+ value,
+ f"group[{group_index}].{key}",
+ )
+ groups.append(
+ {
+ "parameters": group_parameters,
+ "settings": settings,
+ }
+ )
+ missing_parameters = frozenset(parameter_keys).difference(consumed_parameters)
+ if missing_parameters:
+ missing_keys = tuple(parameter_keys[identity] for identity in missing_parameters)
+ raise ValueError(
+ "optimizer schema does not exhaust rebound variant parameters: " f"{missing_keys!r}"
+ )
+ return {
+ "type_chain": _optimizer_type_chain(optimizer),
+ "groups": groups,
+ }
+
+
+def _optimizer_type_chain(optimizer: Any) -> list[str]:
+ """Describe transparent optimizer wrappers without following cycles."""
+ names = []
+ seen: set[int] = set()
+ current = optimizer
+ while current is not None and id(current) not in seen:
+ seen.add(id(current))
+ names.append(_qualified_type_name(type(current)))
+ children = getattr(current, "optimizers", None)
+ if isinstance(children, Sequence) and not isinstance(children, (str, bytes)):
+ names.extend(
+ f"child[{index}]={_qualified_type_name(type(child))}"
+ for index, child in enumerate(children)
+ )
+ nested = getattr(current, "optimizer", None)
+ current = nested if nested is not current else None
+ return names
+
+
+def _canonical_optimizer_value(value: Any, path: str) -> Any:
+ """Convert optimizer group configuration into strict canonical JSON values."""
+ if value is None or type(value) in (bool, int, str):
+ return value
+ if type(value) is float:
+ if not math.isfinite(value):
+ raise ValueError(f"optimizer schema value at {path} must be finite")
+ return value
+ if isinstance(value, torch.dtype):
+ return {"torch_dtype": str(value)}
+ if isinstance(value, torch.device):
+ return {"torch_device": str(value)}
+ if isinstance(value, tuple):
+ return {
+ "tuple": [
+ _canonical_optimizer_value(item, f"{path}[{index}]")
+ for index, item in enumerate(value)
+ ]
+ }
+ if isinstance(value, list):
+ return [
+ _canonical_optimizer_value(item, f"{path}[{index}]") for index, item in enumerate(value)
+ ]
+ if isinstance(value, Mapping):
+ result = {}
+ for key in sorted(value):
+ if type(key) is not str:
+ raise TypeError(
+ f"optimizer schema mapping key at {path} must be str, "
+ f"received {type(key).__name__}: {key!r}"
+ )
+ result[key] = _canonical_optimizer_value(value[key], f"{path}.{key}")
+ return result
+ if callable(value):
+ module = getattr(value, "__module__", None)
+ qualname = getattr(value, "__qualname__", None)
+ if type(module) is str and type(qualname) is str:
+ return {"callable": f"{module}.{qualname}"}
+ raise TypeError(
+ f"unsupported optimizer schema value at {path}: " f"{type(value).__name__}: {value!r}"
+ )
+
+
+def _schema_digest(schema: Any) -> str:
+ """Hash one canonical JSON schema with explicit stable separators."""
+ payload = json.dumps(
+ schema,
+ allow_nan=False,
+ ensure_ascii=True,
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _qualified_type_name(type_: type) -> str:
+ """Return one import-qualified concrete type name."""
+ return f"{type_.__module__}.{type_.__qualname__}"
+
+
+def _require_non_empty_string(value: Any, field_name: str) -> str:
+ """Require a concrete non-empty identity string."""
+ if type(value) is not str or not value:
+ raise TypeError(
+ f"trainer runtime identity {field_name} must be a non-empty str, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ return value
+
+
+def _require_positive_int(value: Any, field_name: str) -> int:
+ """Require a concrete positive identity integer."""
+ if type(value) is not int or value < 1:
+ raise TypeError(
+ f"trainer runtime identity {field_name} must be a positive int, "
+ f"received {type(value).__name__}: {value!r}"
+ )
+ return value
+
+
+__all__ = [
+ "build_default_data_identity_payload",
+ "build_default_execution_identity_payload",
+ "build_trainer_runtime_identity",
+]
diff --git a/src/flow_factory/trainers/common/runtime_state.py b/src/flow_factory/trainers/common/runtime_state.py
index bff6d659d..79948be81 100644
--- a/src/flow_factory/trainers/common/runtime_state.py
+++ b/src/flow_factory/trainers/common/runtime_state.py
@@ -12,18 +12,21 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Safe checkpoint storage for offline trainer progress and child state."""
+"""Safe checkpoint storage for trainer progress and late-bound child state."""
import hashlib
import json
import math
import os
+import random
import re
import uuid
from collections.abc import Iterable, Mapping
-from typing import Any, Protocol
+from typing import Any, Callable, Protocol
+import numpy as np
import torch
+from accelerate.utils import SCALER_NAME
from accelerate.utils import load as accelerate_load
from safetensors.torch import load_file, save_file
@@ -57,6 +60,12 @@
"optimizer_roles",
"parameter_schema_digest",
"optimizer_schema_digest",
+ "execution_contract_digest",
+ "data_contract_digest",
+ "distributed_type",
+ "backend_schema_digest",
+ "mixed_precision",
+ "gradient_scaler",
"world_size",
}
)
@@ -87,7 +96,7 @@ def validate_state_dict(self, state_dict: Mapping[str, Any]) -> None:
class TrainerRuntimeState:
- """Own offline progress and late-bound EMA/reference checkpoint state.
+ """Own unified progress and late-bound EMA/reference checkpoint state.
Runtime payloads intentionally do not use Accelerate's generic custom checkpoint
objects because those objects are serialized with pickle. The framework writes a
@@ -220,6 +229,11 @@ def validate_load(
input_dir: str | os.PathLike[str],
*,
children: Mapping[str, CheckpointableChild] | None = None,
+ invariant_validator: (
+ Callable[[TrainingProgress, Mapping[str, Mapping[str, Any]]], None] | None
+ ) = None,
+ expected_process_index: int | None = None,
+ expected_device_type: str | None = None,
) -> None:
"""Decode and stage compatible state before policy/optimizer mutation."""
if self._load_received:
@@ -242,13 +256,13 @@ def validate_load(
)
if legacy_files:
raise RuntimeError(
- "offline state checkpoint contains legacy or foreign pickle custom state "
+ "state checkpoint contains legacy or foreign pickle custom state "
f"files {legacy_files!r}; this runtime accepts only JSON + safetensors. "
"Resume model weights instead or regenerate a trusted state checkpoint."
)
metadata_path = os.path.join(input_path, TRAINER_RUNTIME_METADATA_FILENAME)
- if not os.path.isfile(metadata_path):
+ if os.path.islink(metadata_path) or not os.path.isfile(metadata_path):
raise RuntimeError(
"state checkpoint is incompatible with trainer runtime-state v1: expected "
f"metadata file {metadata_path!r}, received missing file. Checkpoints created "
@@ -269,6 +283,10 @@ def validate_load(
input_path,
metadata["state_files"],
require_complete=self._identity["trainer"] != "unspecified",
+ expected_process_index=expected_process_index,
+ expected_device_type=expected_device_type,
+ world_size=self._identity["world_size"],
+ require_scaler=self._identity["gradient_scaler"] not in ("none", "unspecified"),
)
tensor_filename = _validate_checkpoint_file(
@@ -281,6 +299,11 @@ def validate_load(
"trainer runtime metadata tensor_file path must be a generated runtime "
f"safetensors basename, received {tensor_filename!r}"
)
+ _validate_manifest_file_set(
+ input_path,
+ state_files=metadata["state_files"],
+ tensor_filename=tensor_filename,
+ )
tensor_path = os.path.join(input_path, tensor_filename)
tensors = load_file(tensor_path, device="cpu")
consumed_tensors: set[str] = set()
@@ -315,6 +338,13 @@ def validate_load(
child = children[name]
_require_checkpointable_child(child, name, require_validator=True)
child.validate_state_dict(child_payloads[name])
+ if invariant_validator is not None:
+ if not callable(invariant_validator):
+ raise TypeError(
+ "trainer runtime invariant_validator must be callable, received "
+ f"{type(invariant_validator).__name__}: {invariant_validator!r}"
+ )
+ invariant_validator(progress, child_payloads)
self._validated_load = (progress, child_payloads)
def commit_validated_load(self) -> None:
@@ -598,6 +628,12 @@ def _normalize_identity(identity: Mapping[str, Any]) -> dict[str, Any]:
"optimizer_roles": (),
"parameter_schema_digest": "unspecified",
"optimizer_schema_digest": "unspecified",
+ "execution_contract_digest": "unspecified",
+ "data_contract_digest": "unspecified",
+ "distributed_type": "unspecified",
+ "backend_schema_digest": "unspecified",
+ "mixed_precision": "unspecified",
+ "gradient_scaler": "unspecified",
"world_size": 1,
}
_require_exact_keys(identity, _IDENTITY_KEYS, "trainer runtime identity")
@@ -610,6 +646,12 @@ def _normalize_identity(identity: Mapping[str, Any]) -> dict[str, Any]:
"finetune_type",
"parameter_schema_digest",
"optimizer_schema_digest",
+ "execution_contract_digest",
+ "data_contract_digest",
+ "distributed_type",
+ "backend_schema_digest",
+ "mixed_precision",
+ "gradient_scaler",
):
value = identity[field_name]
if type(value) is not str or not value:
@@ -655,15 +697,14 @@ def _collect_accelerate_state_files(output_path: str) -> list[dict[str, Any]]:
directory_path = os.path.join(directory, directory_name)
if os.path.islink(directory_path):
raise RuntimeError(
- "offline state checkpoint staging cannot contain symlinked "
+ "state checkpoint staging cannot contain symlinked "
f"directories: {directory_path!r}"
)
for filename in filenames:
file_path = os.path.join(directory, filename)
if os.path.islink(file_path) or not os.path.isfile(file_path):
raise RuntimeError(
- "offline state checkpoint staging requires regular files, "
- f"received {file_path!r}"
+ "state checkpoint staging requires regular files, " f"received {file_path!r}"
)
relative_path = os.path.relpath(file_path, output_path).replace(os.sep, "/")
entries.append(_describe_file(file_path, relative_path))
@@ -679,6 +720,78 @@ def _describe_file(file_path: str, relative_path: str) -> dict[str, Any]:
}
+def _validate_manifest_file_set(
+ input_path: str,
+ *,
+ state_files: Any,
+ tensor_filename: str,
+) -> None:
+ """Reject unmanifested files that a backend loader could otherwise consume."""
+ if not isinstance(state_files, list):
+ raise TypeError("trainer runtime metadata state_files must be a list")
+ expected_paths = {
+ TRAINER_RUNTIME_METADATA_FILENAME,
+ tensor_filename,
+ }
+ for index, entry in enumerate(state_files):
+ entry = _require_mapping(entry, f"trainer runtime state_files[{index}]")
+ relative_path = entry.get("path")
+ if type(relative_path) is not str:
+ raise TypeError(
+ f"trainer runtime state_files[{index}] path must be str, "
+ f"received {type(relative_path).__name__}: {relative_path!r}"
+ )
+ expected_paths.add(relative_path)
+
+ actual_paths = set()
+ for directory, directory_names, filenames in os.walk(input_path):
+ for directory_name in directory_names:
+ directory_path = os.path.join(directory, directory_name)
+ if os.path.islink(directory_path):
+ raise RuntimeError(
+ "state checkpoint cannot contain symlinked directories during resume: "
+ f"{directory_path!r}"
+ )
+ for filename in filenames:
+ file_path = os.path.join(directory, filename)
+ if os.path.islink(file_path) or not os.path.isfile(file_path):
+ raise RuntimeError(
+ "state checkpoint resume requires regular files, " f"received {file_path!r}"
+ )
+ actual_paths.add(os.path.relpath(file_path, input_path).replace(os.sep, "/"))
+ unmanifested_state_paths = tuple(
+ sorted(
+ path
+ for path in actual_paths.difference(expected_paths)
+ if _is_backend_state_candidate(path)
+ )
+ )
+ if unmanifested_state_paths:
+ raise RuntimeError(
+ "state checkpoint contains unmanifested backend artifacts that could be "
+ f"consumed during resume: unmanifested={unmanifested_state_paths!r}"
+ )
+
+
+def _is_backend_state_candidate(path: str) -> bool:
+ """Identify unmanifested names that Accelerate or a backend may consume."""
+ top_level = path.split("/", 1)[0]
+ prefixes = (
+ "model",
+ "pytorch_model",
+ "optimizer",
+ "scheduler",
+ "sampler",
+ "scaler",
+ "random_states",
+ "custom_checkpoint",
+ "dl_state_dict",
+ TRAINER_RUNTIME_TENSOR_PREFIX,
+ "flow_factory_",
+ )
+ return top_level.startswith(prefixes)
+
+
def _file_sha256(file_path: str) -> str:
"""Return the streaming SHA-256 digest for one checkpoint artifact."""
digest = hashlib.sha256()
@@ -740,6 +853,10 @@ def _validate_accelerate_state_files(
entries: Any,
*,
require_complete: bool,
+ expected_process_index: int | None,
+ expected_device_type: str | None,
+ world_size: int,
+ require_scaler: bool,
) -> None:
"""Reject missing/truncated core state and parse RNG before model mutation."""
if not isinstance(entries, list):
@@ -765,30 +882,218 @@ def _validate_accelerate_state_files(
raise ValueError("trainer runtime metadata state_files must be sorted by path")
if not require_complete:
return
+ if type(expected_process_index) is not int:
+ raise TypeError(
+ "exact state preflight requires accelerator.process_index as an int, "
+ f"received {type(expected_process_index).__name__}: "
+ f"{expected_process_index!r}"
+ )
+ if expected_process_index < 0 or expected_process_index >= world_size:
+ raise ValueError(
+ "exact state preflight process index is outside the checkpoint world: "
+ f"process_index={expected_process_index}, world_size={world_size}"
+ )
+ if type(expected_device_type) is not str or not expected_device_type:
+ raise TypeError(
+ "exact state preflight requires accelerator.device.type as a non-empty "
+ f"str, received {type(expected_device_type).__name__}: "
+ f"{expected_device_type!r}"
+ )
paths = frozenset(received_paths)
- if not ({"model.safetensors", "pytorch_model.bin"} & paths):
- raise RuntimeError("offline exact state checkpoint is missing its prepared model artifact")
- for required_path in ("optimizer.bin", "random_states_0.pkl"):
- if required_path not in paths:
- raise RuntimeError(
- "offline exact state checkpoint is missing required artifact " f"{required_path!r}"
- )
+ if not any(_is_prepared_model_artifact(path) for path in paths):
+ raise RuntimeError("exact state checkpoint is missing its prepared model artifact")
+ if not any(_is_optimizer_artifact(path) for path in paths):
+ raise RuntimeError("exact state checkpoint is missing its optimizer artifact")
+ if require_scaler and SCALER_NAME not in paths:
+ raise RuntimeError(
+ "exact state checkpoint is missing the gradient scaler artifact required "
+ "by its runtime identity"
+ )
+ rng_paths = tuple(
+ sorted(
+ path for path in paths if re.fullmatch(r"random_states_[0-9]+\.pkl", path) is not None
+ )
+ )
+ if not rng_paths:
+ raise RuntimeError("exact state checkpoint is missing its per-rank RNG artifact")
+ expected_rng_path = f"random_states_{expected_process_index}.pkl"
+ if expected_rng_path not in rng_paths:
+ raise RuntimeError(
+ "exact state checkpoint is missing the current rank RNG artifact: "
+ f"expected {expected_rng_path!r}, available={rng_paths!r}"
+ )
+ _validate_rng_state(
+ input_path,
+ expected_rng_path,
+ expected_device_type=expected_device_type,
+ )
+
- rng_path = os.path.join(input_path, "random_states_0.pkl")
+def _validate_rng_state(
+ input_path: str,
+ relative_path: str,
+ *,
+ expected_device_type: str,
+) -> None:
+ """Validate the exact RNG payload Accelerate will consume for this rank."""
+ rng_path = os.path.join(input_path, relative_path)
rng_state = accelerate_load(rng_path, map_location="cpu", weights_only=True)
- rng_state = _require_mapping(rng_state, "offline RNG state")
- required_rng_keys = frozenset({"random_state", "numpy_random_seed", "torch_manual_seed"})
+ rng_state = _require_mapping(
+ rng_state,
+ f"exact-resume RNG state {relative_path!r}",
+ )
+ required_rng_keys = {
+ "random_state",
+ "numpy_random_seed",
+ "torch_manual_seed",
+ }
+ device_rng_keys = {
+ "cuda": "torch_cuda_manual_seed",
+ "xpu": "torch_xpu_manual_seed",
+ "mlu": "torch_mlu_manual_seed",
+ "sdaa": "torch_sdaa_manual_seed",
+ "musa": "torch_musa_manual_seed",
+ "hpu": "torch_hpu_manual_seed",
+ "neuron": "torch_neuron_manual_seed",
+ "xla": "xm_seed",
+ }
+ if expected_device_type == "mps":
+ raise RuntimeError(
+ "Accelerate does not serialize the MPS RNG state required for exact "
+ "training resume; use a model-weight resume on MPS"
+ )
+ if expected_device_type != "cpu":
+ device_key = device_rng_keys.get(expected_device_type)
+ if device_key is None:
+ raise RuntimeError(
+ "exact training resume does not recognize the accelerator RNG "
+ f"device type {expected_device_type!r}"
+ )
+ required_rng_keys.add(device_key)
missing_rng_keys = required_rng_keys.difference(rng_state)
if missing_rng_keys:
raise ValueError(
- "offline RNG state is missing required keys: " f"{tuple(sorted(missing_rng_keys))!r}"
+ f"exact-resume RNG state {relative_path!r} is missing required keys: "
+ f"{tuple(sorted(missing_rng_keys))!r}"
)
- if type(rng_state["torch_manual_seed"]) is not torch.Tensor:
+ if "step" in rng_state and (type(rng_state["step"]) is not int or rng_state["step"] < 0):
+ raise ValueError(
+ f"exact-resume RNG state {relative_path!r} step must be a non-negative "
+ f"int, received {rng_state['step']!r}"
+ )
+
+ try:
+ random.Random().setstate(rng_state["random_state"])
+ except (TypeError, ValueError) as error:
+ raise ValueError(
+ f"exact-resume RNG state {relative_path!r} has invalid random_state"
+ ) from error
+ try:
+ np.random.RandomState().set_state(rng_state["numpy_random_seed"])
+ except (TypeError, ValueError) as error:
+ raise ValueError(
+ f"exact-resume RNG state {relative_path!r} has invalid numpy_random_seed"
+ ) from error
+ torch_state = rng_state["torch_manual_seed"]
+ if type(torch_state) is not torch.Tensor:
raise TypeError(
- "offline RNG torch_manual_seed must be a plain torch.Tensor, received "
- f"{type(rng_state['torch_manual_seed']).__name__}"
+ f"exact-resume RNG {relative_path!r} torch_manual_seed must be a "
+ f"plain torch.Tensor, received {type(torch_state).__name__}"
)
+ try:
+ torch.Generator(device="cpu").set_state(torch_state)
+ except RuntimeError as error:
+ raise ValueError(
+ f"exact-resume RNG state {relative_path!r} has invalid torch_manual_seed"
+ ) from error
+
+ if expected_device_type in device_rng_keys and expected_device_type != "xla":
+ device_states = rng_state[device_rng_keys[expected_device_type]]
+ if not isinstance(device_states, (list, tuple)) or not device_states:
+ raise TypeError(
+ f"exact-resume RNG {relative_path!r} device state must be a non-empty "
+ f"sequence, received {type(device_states).__name__}: {device_states!r}"
+ )
+ device_module = getattr(torch, expected_device_type, None)
+ device_count = getattr(device_module, "device_count", None)
+ if not callable(device_count):
+ raise RuntimeError(
+ "exact training resume cannot validate the visible RNG topology for "
+ f"device type {expected_device_type!r}; use a model-weight resume"
+ )
+ visible_device_count = device_count()
+ if type(visible_device_count) is not int or visible_device_count < 1:
+ raise RuntimeError(
+ "exact training resume expected at least one visible device for RNG "
+ f"type {expected_device_type!r}, received {visible_device_count!r}"
+ )
+ if len(device_states) != visible_device_count:
+ raise ValueError(
+ f"exact-resume RNG {relative_path!r} device-state topology mismatch: "
+ f"expected {visible_device_count} visible {expected_device_type} device "
+ f"states, received {len(device_states)}"
+ )
+ for index, device_state in enumerate(device_states):
+ if type(device_state) is not torch.Tensor:
+ raise TypeError(
+ f"exact-resume RNG {relative_path!r} device state {index} must be "
+ f"a plain torch.Tensor, received {type(device_state).__name__}"
+ )
+ if (
+ device_state.device.type != "cpu"
+ or device_state.dtype is not torch.uint8
+ or device_state.ndim != 1
+ or device_state.numel() == 0
+ ):
+ raise ValueError(
+ f"exact-resume RNG {relative_path!r} device state {index} must "
+ "be a non-empty one-dimensional CPU uint8 tensor, received "
+ f"device={device_state.device}, dtype={device_state.dtype}, "
+ f"shape={tuple(device_state.shape)!r}"
+ )
+ try:
+ generator = torch.Generator(device=torch.device(expected_device_type, index))
+ generator.set_state(device_state)
+ except (RuntimeError, TypeError, ValueError) as error:
+ raise ValueError(
+ f"exact-resume RNG {relative_path!r} device state {index} cannot "
+ f"be installed on {expected_device_type}:{index}"
+ ) from error
+ elif expected_device_type == "xla":
+ xm_seed = rng_state[device_rng_keys[expected_device_type]]
+ if type(xm_seed) is not int and type(xm_seed) is not torch.Tensor:
+ raise TypeError(
+ f"exact-resume RNG {relative_path!r} xm_seed must be an int or plain "
+ f"torch.Tensor, received {type(xm_seed).__name__}"
+ )
+
+
+def _is_prepared_model_artifact(path: str) -> bool:
+ """Recognize Accelerate model artifacts across plain, FSDP, and DeepSpeed."""
+ basename = path.rsplit("/", 1)[-1]
+ return (
+ basename in {"model.safetensors", "pytorch_model.bin"}
+ or (basename.startswith("pytorch_model_fsdp") and basename.endswith(".bin"))
+ or path.startswith("pytorch_model_fsdp_")
+ or (
+ path.startswith("pytorch_model/")
+ and "model_states" in basename.lower()
+ and "optim_states" not in basename.lower()
+ )
+ )
+
+
+def _is_optimizer_artifact(path: str) -> bool:
+ """Recognize standalone or backend-integrated Accelerate optimizer state."""
+ basename = path.rsplit("/", 1)[-1]
+ return (
+ basename == "optimizer.bin"
+ or (basename.startswith("optimizer_") and basename.endswith(".bin"))
+ or path.startswith("optimizer_")
+ # DeepSpeed stores optimizer shards inside its model checkpoint directory.
+ or (path.startswith("pytorch_model/") and "optim" in basename.lower())
+ )
def _validate_metadata_header(
diff --git a/src/flow_factory/trainers/distillation/distillation_runtime.py b/src/flow_factory/trainers/distillation/distillation_runtime.py
index da75db8d6..daab3ab81 100644
--- a/src/flow_factory/trainers/distillation/distillation_runtime.py
+++ b/src/flow_factory/trainers/distillation/distillation_runtime.py
@@ -18,6 +18,7 @@
import inspect
import math
+import random
import zlib
from contextlib import contextmanager
from numbers import Real
@@ -37,6 +38,7 @@
TypeVar,
)
+import numpy as np
import torch
from tqdm import tqdm
@@ -127,9 +129,10 @@ def empty_media(*latent_inputs: Any, **kwargs: Any) -> Any:
for component, tensor in value.components.items()
if isinstance(tensor, torch.Tensor) and tensor.ndim >= 1
}
- if len(component_sizes) != len(value.components) or len(
- set(component_sizes.values())
- ) != 1:
+ if (
+ len(component_sizes) != len(value.components)
+ or len(set(component_sizes.values())) != 1
+ ):
raise ValueError(
f"{algorithm_name} media-free decoder adapter={adapter_name!r}, "
f"signature={decoder_signature} received invalid LatentState batch "
@@ -442,24 +445,34 @@ def generate_one_rollout_batch(
"dataloader exists. `data.datasets` has no entry with `train: enabled` "
"(eval-only config); a trainer should not enter the sampling loop here."
)
- if not hasattr(trainer, "_rollout_dataloader_epoch"):
- trainer._rollout_dataloader_epoch = 0
if not hasattr(trainer, "_rollout_data_iter"):
trainer._rollout_data_iter = None
+ if not hasattr(trainer, "_rollout_batches_consumed"):
+ trainer._rollout_batches_consumed = None
trainer.adapter.rollout()
if trainer._rollout_data_iter is None:
- if hasattr(trainer.dataloader, "set_epoch"):
- trainer.dataloader.set_epoch(trainer._rollout_dataloader_epoch)
- trainer._rollout_data_iter = iter(trainer.dataloader)
+ _restore_rollout_data_cursor(
+ trainer,
+ consumed_batches=_completed_rollout_batch_count(trainer),
+ algorithm_name=algorithm_name,
+ )
try:
batch = next(trainer._rollout_data_iter)
except StopIteration:
- trainer._rollout_dataloader_epoch += 1
- if hasattr(trainer.dataloader, "set_epoch"):
- trainer.dataloader.set_epoch(trainer._rollout_dataloader_epoch)
- trainer._rollout_data_iter = iter(trainer.dataloader)
- batch = next(trainer._rollout_data_iter)
+ _restore_rollout_data_cursor(
+ trainer,
+ consumed_batches=trainer._rollout_batches_consumed,
+ algorithm_name=algorithm_name,
+ )
+ try:
+ batch = next(trainer._rollout_data_iter)
+ except StopIteration as error:
+ raise RuntimeError(
+ f"{algorithm_name} training dataloader produced no batches after "
+ "restoring its deterministic rollout cursor"
+ ) from error
+ trainer._rollout_batches_consumed += 1
with trainer._rollout_acceleration(), torch.no_grad(), trainer.autocast():
return trainer.sample_batch(
@@ -470,6 +483,183 @@ def generate_one_rollout_batch(
)
+def _completed_rollout_batch_count(trainer: Any) -> int:
+ """Derive the next rollout batch from checkpointed acquisition progress."""
+ progress = getattr(trainer, "progress", None)
+ completed_iterations = getattr(progress, "rollout_iteration", 0)
+ training_args = getattr(trainer, "training_args", None)
+ accumulation_steps = getattr(training_args, "gradient_accumulation_steps", 1)
+ if (
+ not isinstance(completed_iterations, int)
+ or isinstance(completed_iterations, bool)
+ or completed_iterations < 0
+ ):
+ raise ValueError(
+ "expected rollout_iteration >= 0 as an int, received "
+ f"{type(completed_iterations).__name__}: {completed_iterations!r}"
+ )
+ if (
+ not isinstance(accumulation_steps, int)
+ or isinstance(accumulation_steps, bool)
+ or accumulation_steps < 1
+ ):
+ raise ValueError(
+ "expected gradient_accumulation_steps >= 1 as an int, received "
+ f"{type(accumulation_steps).__name__}: {accumulation_steps!r}"
+ )
+ return completed_iterations * accumulation_steps
+
+
+def _collect_rollout_loader_generators(dataloader: Any) -> List[torch.Generator]:
+ """Collect explicit generators whose state iterator construction may advance."""
+ generators: List[torch.Generator] = []
+ seen_generators: set[int] = set()
+ seen_nodes: set[int] = set()
+
+ def add_generator(value: Any) -> None:
+ if not isinstance(value, torch.Generator) or id(value) in seen_generators:
+ return
+ seen_generators.add(id(value))
+ generators.append(value)
+
+ def visit_sampler(sampler: Any) -> None:
+ if sampler is None or id(sampler) in seen_nodes:
+ return
+ seen_nodes.add(id(sampler))
+ add_generator(getattr(sampler, "generator", None))
+ nested_sampler = getattr(sampler, "sampler", None)
+ if nested_sampler is not sampler:
+ visit_sampler(nested_sampler)
+
+ def visit_loader(loader: Any) -> None:
+ if loader is None or id(loader) in seen_nodes:
+ return
+ seen_nodes.add(id(loader))
+ add_generator(getattr(loader, "generator", None))
+ visit_sampler(getattr(loader, "sampler", None))
+ visit_sampler(getattr(loader, "batch_sampler", None))
+ loaders_by_source = getattr(loader, "dataloaders_by_source", None)
+ if loaders_by_source is None:
+ loaders_by_source = getattr(loader, "_loaders_by_source", None)
+ if isinstance(loaders_by_source, Mapping):
+ for source_loader in loaders_by_source.values():
+ visit_loader(source_loader)
+
+ visit_loader(dataloader)
+ return generators
+
+
+@contextmanager
+def _preserve_rollout_cursor_rng(dataloader: Any) -> Iterator[None]:
+ """Restore every supported parent-process RNG after cursor reconstruction."""
+ python_state = random.getstate()
+ numpy_state = np.random.get_state()
+ cpu_state = torch.random.get_rng_state()
+ cuda_states = None
+ if torch.cuda.is_available() and torch.cuda.device_count() > 0:
+ cuda_states = torch.cuda.get_rng_state_all()
+ mps_state = None
+ if torch.backends.mps.is_available() and hasattr(torch.mps, "get_rng_state"):
+ mps_state = torch.mps.get_rng_state()
+ generators = _collect_rollout_loader_generators(dataloader)
+ generator_states = [generator.get_state() for generator in generators]
+ try:
+ yield
+ finally:
+ random.setstate(python_state)
+ np.random.set_state(numpy_state)
+ torch.random.set_rng_state(cpu_state)
+ if cuda_states is not None:
+ torch.cuda.set_rng_state_all(cuda_states)
+ if mps_state is not None:
+ torch.mps.set_rng_state(mps_state)
+ for generator, state in zip(generators, generator_states):
+ generator.set_state(state)
+
+
+def _materialize_lazy_rollout_iterators(dataloader: Any) -> None:
+ """Initialize known lazy loader children inside the RNG-preserving scope."""
+ ensure_iters = getattr(dataloader, "_ensure_iters", None)
+ if callable(ensure_iters):
+ ensure_iters()
+
+
+def _restore_rollout_data_cursor(
+ trainer: Any,
+ *,
+ consumed_batches: int,
+ algorithm_name: str,
+) -> None:
+ """Rebuild one deterministic loader iterator at a global batch boundary.
+
+ Exact checkpoints are published only between acquisition cycles. Each completed
+ distillation cycle consumes exactly ``gradient_accumulation_steps`` batches, so
+ the persisted rollout-iteration counter is the authoritative cursor. Rebuilding
+ from it avoids serializing a Python iterator and works for both finite
+ multi-source loaders and the framework's infinite grouped batch samplers.
+ """
+ if (
+ not isinstance(consumed_batches, int)
+ or isinstance(consumed_batches, bool)
+ or consumed_batches < 0
+ ):
+ raise ValueError(
+ f"{algorithm_name} expected consumed rollout batches >= 0 as an int, "
+ f"received {type(consumed_batches).__name__}: {consumed_batches!r}"
+ )
+ epoch_size = _rollout_batches_per_dataloader_epoch(trainer, algorithm_name)
+ dataloader_epoch, batch_offset = divmod(consumed_batches, epoch_size)
+ with _preserve_rollout_cursor_rng(trainer.dataloader):
+ _set_rollout_dataloader_epoch(trainer.dataloader, dataloader_epoch)
+ data_iter = iter(trainer.dataloader)
+ _materialize_lazy_rollout_iterators(trainer.dataloader)
+ for _ in range(batch_offset):
+ try:
+ next(data_iter)
+ except StopIteration as error:
+ raise RuntimeError(
+ f"{algorithm_name} training dataloader ended before its resolved "
+ f"num_batches_per_epoch={epoch_size} while restoring batch offset "
+ f"{batch_offset}"
+ ) from error
+ trainer._rollout_data_iter = data_iter
+ trainer._rollout_batches_consumed = consumed_batches
+
+
+def _rollout_batches_per_dataloader_epoch(trainer: Any, algorithm_name: str) -> int:
+ """Resolve the immutable online sampler epoch geometry."""
+ try:
+ epoch_size = len(trainer.dataloader)
+ except (TypeError, AttributeError):
+ epoch_size = None
+ if epoch_size is None:
+ batch_sampler = getattr(trainer.dataloader, "batch_sampler", None)
+ epoch_size = getattr(batch_sampler, "num_batches_per_epoch", None)
+ if epoch_size is None:
+ training_args = getattr(trainer, "training_args", None)
+ epoch_size = getattr(training_args, "num_batches_per_epoch", None)
+ if not isinstance(epoch_size, int) or isinstance(epoch_size, bool) or epoch_size < 1:
+ raise ValueError(
+ f"{algorithm_name} exact rollout cursor requires a positive "
+ "num_batches_per_epoch from dataloader, batch_sampler, or training_args; "
+ f"received {epoch_size!r}"
+ )
+ return epoch_size
+
+
+def _set_rollout_dataloader_epoch(dataloader: Any, epoch: int) -> None:
+ """Set one framework loader or its official/custom sampler epoch."""
+ set_epoch = getattr(dataloader, "set_epoch", None)
+ if callable(set_epoch):
+ set_epoch(epoch)
+ return
+ for name in ("batch_sampler", "sampler"):
+ sampler_set_epoch = getattr(getattr(dataloader, name, None), "set_epoch", None)
+ if callable(sampler_set_epoch):
+ sampler_set_epoch(epoch)
+ return
+
+
def role_repeat_progress(trainer: Any, *, role_name: str, repeats: int) -> Iterator[int]:
"""Report progress through a role's repeated phases.
@@ -631,8 +821,9 @@ def run_role_phase(
# checkpointing recomputes the forward during backward; if the inner loss
# context has already restored another variant, FSDP1 observes a different
# graph (and, worse, can recompute with the wrong role's weights).
- with trainer.role_optimization.phase(role_name), trainer.adapter.use_component_variant(
- role_name
+ with (
+ trainer.role_optimization.phase(role_name),
+ trainer.adapter.use_component_variant(role_name),
):
# A single item would render a 1/1 bar once per TTUR repeat, which is
# noise; the role's own progress is already carried by the caller's bar.
diff --git a/src/flow_factory/trainers/distillation/dmd2.py b/src/flow_factory/trainers/distillation/dmd2.py
index 66a507c8d..0c1a4d032 100644
--- a/src/flow_factory/trainers/distillation/dmd2.py
+++ b/src/flow_factory/trainers/distillation/dmd2.py
@@ -103,7 +103,7 @@ def __init__(
self.training_args: DMD2TrainingArguments
self._validate_generation_schedule()
self._rollout_data_iter: Optional[Iterator[Any]] = None
- self._rollout_dataloader_epoch = 0
+ self._rollout_batches_consumed: Optional[int] = None
non_ode = {
name: scheduler.dynamics_type
for name, scheduler in self.adapter.scheduler_group.items()
diff --git a/src/flow_factory/trainers/distillation/opd/trainer.py b/src/flow_factory/trainers/distillation/opd/trainer.py
index 6636822f7..eabc0bc00 100644
--- a/src/flow_factory/trainers/distillation/opd/trainer.py
+++ b/src/flow_factory/trainers/distillation/opd/trainer.py
@@ -93,6 +93,70 @@ class DiffusionOPDTrainer(BaseTrainer):
paradigm = "distillation"
execution_contract: ClassVar[ExecutionContract] = ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT
+ def _algorithm_runtime_child_names(self) -> Tuple[str, ...]:
+ """Declare teacher snapshot names directly from validated configuration."""
+ return tuple(
+ teacher.name or f"opd_teacher_{index}"
+ for index, teacher in enumerate(self.training_args.teachers)
+ )
+
+ def _initialize_snapshots(self) -> None:
+ """Realize every teacher snapshot before exact-state compatibility checks.
+
+ Exact resume creates shape-compatible placeholders rather than loading the
+ external teacher checkpoints and mutating the prepared student before the
+ runtime manifest has passed preflight. The checkpoint payload replaces those
+ placeholders after Accelerator restores the core state.
+ """
+ teacher_names = self._algorithm_runtime_child_names()
+ state_resume = bool(self.model_args.resume_path and self.model_args.resume_type == "state")
+ if state_resume:
+ if self.model_args.finetune_type != "lora":
+ raise ValueError(
+ "DiffusionOPD teacher snapshots require LoRA finetuning, but "
+ f"model_args.finetune_type={self.model_args.finetune_type!r}."
+ )
+ target_components = [
+ component
+ for component, modules in self.adapter.target_module_map.items()
+ if modules
+ ]
+ if not target_components:
+ raise ValueError(
+ "DiffusionOPD adapter has no trainable LoRA components for "
+ "teacher snapshot placeholders."
+ )
+ snapshot_device = (
+ self.accelerator.device
+ if self.training_args.teacher_param_device == "cuda"
+ else torch.device("cpu")
+ )
+ for teacher_name in teacher_names:
+ self.adapter.add_named_parameters(
+ teacher_name,
+ target_components=target_components,
+ device=snapshot_device,
+ overwrite=True,
+ )
+ loaded_names = list(teacher_names)
+ else:
+ teachers = self.training_args.teachers
+ loaded_names = load_teachers(
+ self.adapter,
+ [teacher.path for teacher in teachers],
+ self.training_args.teacher_param_device,
+ [teacher.name for teacher in teachers],
+ )
+ if tuple(loaded_names) != teacher_names:
+ raise RuntimeError(
+ "DiffusionOPD teacher snapshot declaration drifted during loading: "
+ f"declared={teacher_names!r}, loaded={tuple(loaded_names)!r}"
+ )
+
+ self._teacher_names = loaded_names
+ for teacher_name in teacher_names:
+ self._register_named_parameter_runtime_child(teacher_name)
+
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.training_args: DiffusionOPDTrainingArguments
@@ -109,14 +173,9 @@ def __init__(self, **kwargs: Any) -> None:
float(scheduler_group.primary.noise_level) if self._is_sde else 0.0
)
- # --- Teachers: load each LoRA checkpoint into a named snapshot ---
+ # Teacher snapshots are initialized by the BaseTrainer lifecycle before
+ # exact-state compatibility preflight.
teachers = self.training_args.teachers
- self._teacher_names: List[str] = load_teachers(
- self.adapter,
- [teacher.path for teacher in teachers],
- self.training_args.teacher_param_device,
- [teacher.name for teacher in teachers],
- )
student_gs = float(self.training_args.guidance_scale)
self._teacher_gs: List[float] = [
float(teacher.guidance_scale) if teacher.guidance_scale is not None else student_gs
diff --git a/src/flow_factory/trainers/distillation/tdm.py b/src/flow_factory/trainers/distillation/tdm.py
index 2dc42f1c1..5c863d186 100644
--- a/src/flow_factory/trainers/distillation/tdm.py
+++ b/src/flow_factory/trainers/distillation/tdm.py
@@ -114,7 +114,7 @@ def __init__(
super().__init__(accelerator=accelerator, config=config, adapter=adapter)
self.training_args: TDMTrainingArguments
self._rollout_data_iter: Iterator[Any] | None = None
- self._rollout_dataloader_epoch = 0
+ self._rollout_batches_consumed: int | None = None
self._validate_trajectory_configuration()
def _init_reward_model(self) -> Tuple[Dict[str, object], Dict[str, object]]:
diff --git a/src/flow_factory/trainers/distillation/tdm_r1.py b/src/flow_factory/trainers/distillation/tdm_r1.py
index 4eb978480..c00fb8497 100644
--- a/src/flow_factory/trainers/distillation/tdm_r1.py
+++ b/src/flow_factory/trainers/distillation/tdm_r1.py
@@ -81,6 +81,11 @@ def __init__(
super().__init__(accelerator=accelerator, config=config, adapter=adapter)
self.training_args: TDMR1TrainingArguments
+ def _initialize_snapshots(self) -> None:
+ """Declare the slow surrogate before exact-state compatibility preflight."""
+ super()._initialize_snapshots()
+ self.adapter.declare_variant_snapshot("surrogate", SLOW_SURROGATE_SNAPSHOT)
+
def _init_reward_model(self):
"""Use train-time rewards instead of TDM's reward-free runtime."""
return BaseTrainer._init_reward_model(self)
@@ -150,7 +155,7 @@ def _surrogate_phase(self, boundary_units: Sequence[TDMBoundaryUnit]) -> None:
record_distillation_metric(self, "train/surrogate_slow_decay", decay)
def _ensure_slow_surrogate(self) -> None:
- """Create the trust-region snapshot after trainable roles exist."""
+ """Retain lazy compatibility for lightweight, non-constructor test hosts."""
if not self.adapter.has_variant_snapshot(SLOW_SURROGATE_SNAPSHOT):
self.adapter.declare_variant_snapshot("surrogate", SLOW_SURROGATE_SNAPSHOT)
diff --git a/src/flow_factory/trainers/multirole/__init__.py b/src/flow_factory/trainers/multirole/__init__.py
index c9353c556..abeaf5475 100644
--- a/src/flow_factory/trainers/multirole/__init__.py
+++ b/src/flow_factory/trainers/multirole/__init__.py
@@ -5,11 +5,12 @@
configure_deepspeed_micro_batch_size,
validate_supported_distributed_plan,
)
-from .checkpointing import MultiRoleCheckpointingMixin
+from .checkpointing import MULTIROLE_RUNTIME_CHILD_NAME, MultiRoleCheckpointingMixin
__all__ = [
"MultiRoleBackendValidationMixin",
"MultiRoleCheckpointingMixin",
+ "MULTIROLE_RUNTIME_CHILD_NAME",
"configure_deepspeed_micro_batch_size",
"validate_supported_distributed_plan",
]
diff --git a/src/flow_factory/trainers/multirole/checkpointing.py b/src/flow_factory/trainers/multirole/checkpointing.py
index 44faa1909..715a2c83e 100644
--- a/src/flow_factory/trainers/multirole/checkpointing.py
+++ b/src/flow_factory/trainers/multirole/checkpointing.py
@@ -7,8 +7,11 @@
import torch
+from ..execution import TrainingProgress
+
MULTIROLE_METADATA_FILENAME = "flow_factory_multirole_metadata.json"
MULTIROLE_METADATA_VERSION = 1
+MULTIROLE_RUNTIME_CHILD_NAME = "multirole"
MULTIROLE_STATE_KEYS = {
"version",
"metadata",
@@ -32,6 +35,25 @@ def load_state_dict(self, state: Mapping[str, Any]) -> None:
"""Restore multi-role counters after Accelerate restores prepared state."""
self._trainer._load_multirole_state_dict(state)
+ def validate_state_dict(self, state: Mapping[str, Any]) -> None:
+ """Validate counters and snapshots without mutating live trainer state."""
+ self._trainer._validate_multirole_state_dict(state)
+
+ def validate_runtime_progress(
+ self,
+ progress: TrainingProgress,
+ state: Mapping[str, Any],
+ ) -> None:
+ """Require the runtime's optimizer counter to equal the primary role."""
+ self.validate_state_dict(state)
+ trainer_step = state["trainer_step"]
+ if progress.optimizer_step != trainer_step:
+ raise ValueError(
+ "registered multi-role counter mismatch with trainer runtime progress: "
+ f"expected optimizer_step={progress.optimizer_step}, received "
+ f"trainer_step={trainer_step}"
+ )
+
def prepare_save(self, output_dir: str) -> None:
"""Validate a closed boundary and write metadata before Accelerate saves."""
try:
@@ -149,11 +171,59 @@ def _multirole_state_dict(self) -> dict[str, Any]:
"metadata": self._multirole_metadata(),
"coordinator": coordinator_state,
"trainer_step": self.step,
- "variant_snapshots": self.adapter.component_variant_registry.snapshot_state_dict(),
+ "variant_snapshots": self._multirole_snapshot_state_dict(),
+ }
+
+ def _multirole_snapshot_state_dict(self) -> dict[str, Any]:
+ """Return snapshot references for the immediately synchronous serializer.
+
+ ``TrainerRuntimeState`` performs the one required CPU clone while extracting
+ tensors into safetensors. Cloning every full-model snapshot here first would
+ transiently double accelerator memory before that extraction begins.
+ """
+ registry = self.adapter.component_variant_registry
+ snapshots = getattr(registry, "_snapshots", None)
+ if not isinstance(snapshots, Mapping):
+ raise TypeError(
+ "component variant registry must expose snapshot declarations as a "
+ f"mapping, received {type(snapshots).__name__}"
+ )
+ return {
+ "version": 1,
+ "snapshots": {
+ snapshot_name: {
+ "variant_name": snapshot["variant_name"],
+ "parameters": dict(snapshot["parameters"]),
+ }
+ for snapshot_name, snapshot in snapshots.items()
+ },
+ "update_counts": {
+ snapshot_name: snapshot["update_count"]
+ for snapshot_name, snapshot in snapshots.items()
+ },
}
def _load_multirole_state_dict(self, state: Mapping[str, Any]) -> None:
"""Restore custom multi-role counters after complete validation."""
+ self._validate_multirole_state_dict(state)
+ trainer_step = state["trainer_step"]
+ runtime_state = getattr(self, "runtime_state", None)
+ if runtime_state is not None and getattr(runtime_state, "load_received", False):
+ if self.step != trainer_step:
+ raise ValueError(
+ "registered multi-role counter mismatch with trainer runtime progress: "
+ f"expected optimizer_step={self.step}, received trainer_step={trainer_step}"
+ )
+ else:
+ self.step = trainer_step
+ self.role_optimization.load_state_dict(state["coordinator"])
+ self.adapter.component_variant_registry.load_snapshot_state_dict(state["variant_snapshots"])
+ self.adapter.component_variant_registry.activate(
+ self.adapter.component_variant_registry.base_variant
+ )
+
+ def _validate_multirole_state_dict(self, state: Mapping[str, Any]) -> None:
+ """Validate custom multi-role state without changing counters or snapshots."""
if not isinstance(state, Mapping):
raise TypeError(
"expected registered multi-role state as a mapping, "
@@ -200,12 +270,155 @@ def _load_multirole_state_dict(self, state: Mapping[str, Any]) -> None:
f"{trainer_step} to equal {primary_role!r} role step, "
f"received {primary_role!r} step {received_primary_step!r}"
)
- self.role_optimization.load_state_dict(coordinator_state)
- self.adapter.component_variant_registry.load_snapshot_state_dict(state["variant_snapshots"])
- self.step = trainer_step
- self.adapter.component_variant_registry.activate(
- self.adapter.component_variant_registry.base_variant
- )
+ self._validate_multirole_coordinator_state(coordinator_state)
+ self._validate_multirole_snapshot_state(state["variant_snapshots"])
+
+ def _validate_multirole_coordinator_state(self, state: Mapping[str, Any]) -> None:
+ """Validate the coordinator payload without applying received role steps."""
+ expected = self.role_optimization.state_dict()
+ expected_keys = set(expected)
+ received_keys = set(state)
+ if received_keys != expected_keys:
+ raise ValueError(
+ "multi-role coordinator state keys mismatch: expected "
+ f"{tuple(sorted(expected_keys))!r}, received "
+ f"{tuple(sorted(received_keys))!r}"
+ )
+ if state["version"] != expected["version"]:
+ raise ValueError(
+ "multi-role coordinator state version mismatch: expected "
+ f"{expected['version']!r}, received {state['version']!r}"
+ )
+ if state["active_phase"] is not None:
+ raise ValueError(
+ "multi-role coordinator state expected active_phase=None, received "
+ f"{state['active_phase']!r}"
+ )
+ if state["optimizer_group_roles"] != expected["optimizer_group_roles"]:
+ raise ValueError(
+ "multi-role coordinator optimizer_group_roles mismatch: expected "
+ f"{expected['optimizer_group_roles']!r}, received "
+ f"{state['optimizer_group_roles']!r}"
+ )
+ role_steps = state["role_steps"]
+ expected_role_names = tuple(expected["role_steps"])
+ if not isinstance(role_steps, Mapping) or tuple(role_steps) != expected_role_names:
+ received_role_names = (
+ tuple(role_steps) if isinstance(role_steps, Mapping) else role_steps
+ )
+ raise ValueError(
+ "multi-role coordinator role_steps mismatch: expected roles "
+ f"{expected_role_names!r}, received {received_role_names!r}"
+ )
+ for role_name, role_step in role_steps.items():
+ if not isinstance(role_step, int) or isinstance(role_step, bool) or role_step < 0:
+ raise ValueError(
+ "multi-role coordinator expected non-negative int step for "
+ f"{role_name!r}, received {role_step!r}"
+ )
+
+ def _validate_multirole_snapshot_state(self, state: Mapping[str, Any]) -> None:
+ """Validate variant snapshot tensors without copying into live snapshots."""
+ if not isinstance(state, Mapping):
+ raise TypeError(
+ "expected parameter EMA state as a mapping, "
+ f"received {type(state).__name__}: {state!r}"
+ )
+ registry = self.adapter.component_variant_registry
+ # Validation must not clone full-model snapshots merely to inspect their
+ # schema. The registry owns this immutable declaration mapping; received
+ # values are copied only later by its public load_snapshot_state_dict().
+ expected_snapshots = getattr(registry, "_snapshots", None)
+ if not isinstance(expected_snapshots, Mapping):
+ raise TypeError(
+ "component variant registry must expose snapshot declarations as a "
+ f"mapping, received {type(expected_snapshots).__name__}"
+ )
+ expected_keys = {"version", "snapshots", "update_counts"}
+ if set(state) != expected_keys or state.get("version") != 1:
+ raise ValueError(
+ "multi-role snapshot state keys/version mismatch: expected keys "
+ f"{tuple(sorted(expected_keys))!r} and version 1, "
+ f"received keys={tuple(sorted(state))!r}, version={state.get('version')!r}"
+ )
+ snapshots = state["snapshots"]
+ update_counts = state["update_counts"]
+ if not isinstance(snapshots, Mapping) or tuple(snapshots) != tuple(expected_snapshots):
+ received_names = tuple(snapshots) if isinstance(snapshots, Mapping) else snapshots
+ raise ValueError(
+ "multi-role snapshot names mismatch: expected "
+ f"{tuple(expected_snapshots)!r}, received {received_names!r}"
+ )
+ if not isinstance(update_counts, Mapping) or tuple(update_counts) != tuple(
+ expected_snapshots
+ ):
+ received_names = (
+ tuple(update_counts) if isinstance(update_counts, Mapping) else update_counts
+ )
+ raise ValueError(
+ "multi-role snapshot update-count names mismatch: expected "
+ f"{tuple(expected_snapshots)!r}, received {received_names!r}"
+ )
+ for snapshot_name, expected_snapshot in expected_snapshots.items():
+ received_snapshot = snapshots[snapshot_name]
+ if not isinstance(received_snapshot, Mapping):
+ raise TypeError(
+ f"multi-role snapshot {snapshot_name!r} must be a mapping, "
+ f"received {type(received_snapshot).__name__}: {received_snapshot!r}"
+ )
+ if set(received_snapshot) != {"variant_name", "parameters"}:
+ raise ValueError(
+ f"multi-role snapshot {snapshot_name!r} keys mismatch: expected "
+ "('parameters', 'variant_name'), received "
+ f"{tuple(sorted(received_snapshot))!r}"
+ )
+ if received_snapshot["variant_name"] != expected_snapshot["variant_name"]:
+ raise ValueError(
+ f"multi-role snapshot {snapshot_name!r} variant mismatch: expected "
+ f"{expected_snapshot['variant_name']!r}, received "
+ f"{received_snapshot['variant_name']!r}"
+ )
+ received_parameters = received_snapshot["parameters"]
+ expected_parameters = expected_snapshot["parameters"]
+ if not isinstance(received_parameters, Mapping) or tuple(received_parameters) != tuple(
+ expected_parameters
+ ):
+ received_names = (
+ tuple(received_parameters)
+ if isinstance(received_parameters, Mapping)
+ else received_parameters
+ )
+ raise ValueError(
+ f"multi-role snapshot {snapshot_name!r} parameter names mismatch: "
+ f"expected {tuple(expected_parameters)!r}, received {received_names!r}"
+ )
+ for parameter_name, expected_tensor in expected_parameters.items():
+ received_tensor = received_parameters[parameter_name]
+ if type(received_tensor) is not torch.Tensor:
+ raise TypeError(
+ f"multi-role snapshot {snapshot_name!r}/{parameter_name!r} must "
+ f"be a plain torch.Tensor, received {type(received_tensor).__name__}"
+ )
+ if (
+ received_tensor.shape != expected_tensor.shape
+ or received_tensor.dtype != expected_tensor.dtype
+ ):
+ raise ValueError(
+ f"multi-role snapshot {snapshot_name!r}/{parameter_name!r} tensor "
+ f"metadata mismatch: expected shape={tuple(expected_tensor.shape)}, "
+ f"dtype={expected_tensor.dtype}, received "
+ f"shape={tuple(received_tensor.shape)}, dtype={received_tensor.dtype}"
+ )
+ update_count = update_counts[snapshot_name]
+ if (
+ not isinstance(update_count, int)
+ or isinstance(update_count, bool)
+ or update_count < 0
+ ):
+ raise ValueError(
+ f"multi-role snapshot {snapshot_name!r} update count must be a "
+ f"non-negative int, received {update_count!r}"
+ )
def _register_multirole_checkpointing(self) -> None:
"""Register Accelerate metadata gates and custom state for multi-role runs."""
@@ -234,5 +447,16 @@ def load_metadata_hook(models: list[torch.nn.Module], input_dir: str) -> None:
self.adapter._multirole_checkpoint_state = checkpoint_state
self.accelerator.register_save_state_pre_hook(save_metadata_hook)
self.accelerator.register_load_state_pre_hook(load_metadata_hook)
- self.accelerator.register_for_checkpointing(checkpoint_state)
+ # Real trainers serialize this object through TrainerRuntimeState as strict
+ # JSON+safetensors. Lightweight legacy hosts without that runtime retain the
+ # old direct-Accelerate behavior for compatibility tests and external users.
+ if getattr(self, "runtime_state", None) is None:
+ self.accelerator.register_for_checkpointing(checkpoint_state)
self._multirole_checkpoint_registered = True
+
+
+__all__ = [
+ "MULTIROLE_METADATA_FILENAME",
+ "MULTIROLE_RUNTIME_CHILD_NAME",
+ "MultiRoleCheckpointingMixin",
+]
diff --git a/src/flow_factory/trainers/rl/crd.py b/src/flow_factory/trainers/rl/crd.py
index 8e6908afb..17401e769 100644
--- a/src/flow_factory/trainers/rl/crd.py
+++ b/src/flow_factory/trainers/rl/crd.py
@@ -176,6 +176,7 @@ class CRDTrainer(BaseTrainer):
_OLD_PARAMS_NAME = "_crd_old"
_SAMPLING_PARAMS_NAME = "_crd_sampling"
+ runtime_child_names = (_OLD_PARAMS_NAME, _SAMPLING_PARAMS_NAME)
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -211,14 +212,11 @@ def __init__(self, **kwargs):
)
self.kl_type = "v-based"
- # Initialize model snapshots: "old" (for implicit reward) and "sampling" (for rollout)
- self._init_model_snapshots()
-
# ========================= Initialization =========================
- def _init_model_snapshots(self):
+ def _initialize_snapshots(self) -> None:
"""
- Initialize both model snapshots by storing copies of current trainable parameters.
+ Initialize and register both model snapshots before exact state resume.
In the original CRD, this corresponds to:
- ``transformer.add_adapter("old", ...)`` + copy from "default"
@@ -231,6 +229,7 @@ def _init_model_snapshots(self):
name=self._OLD_PARAMS_NAME,
device=ref_device,
)
+ self._register_named_parameter_runtime_child(self._OLD_PARAMS_NAME)
logger.info("CRD: Initialized 'old' model snapshot for implicit reward estimation.")
# Sampling model snapshot (for off-policy rollout generation)
@@ -238,6 +237,7 @@ def _init_model_snapshots(self):
name=self._SAMPLING_PARAMS_NAME,
device=ref_device,
)
+ self._register_named_parameter_runtime_child(self._SAMPLING_PARAMS_NAME)
logger.info("CRD: Initialized 'sampling' model snapshot for rollout generation.")
@property
@@ -262,8 +262,8 @@ def sampling_context(self):
# ========================= Main Training Loop =========================
- def _after_optimizer_step(self) -> None:
- """Advance CRD's two auxiliary snapshots alongside the optimizer EMA."""
+ def _after_acquisition_cycle(self) -> None:
+ """Advance CRD's two auxiliary snapshots after each rollout iteration."""
self._update_old_model()
self._update_sampling_model()
diff --git a/src/flow_factory/trainers/rl/dgpo.py b/src/flow_factory/trainers/rl/dgpo.py
index 90da9ffda..d9acb251d 100644
--- a/src/flow_factory/trainers/rl/dgpo.py
+++ b/src/flow_factory/trainers/rl/dgpo.py
@@ -128,6 +128,37 @@ class DGPOTrainer(BaseTrainer):
# Decoupled paradigm: lossy rollout acceleration is permitted (constraints.md #7).
paradigm = "decoupled"
+ runtime_child_names = ("ema_ref",)
+
+ def _algorithm_runtime_child_names(self) -> Tuple[str, ...]:
+ """Declare the old-policy snapshot only when DGPO consumes it."""
+ training_args: DGPOTrainingArguments = self.training_args # type: ignore[assignment]
+ requires_ema_ref = (
+ training_args.clip_dsm or training_args.clip_kl or training_args.use_ema_ref
+ )
+ return type(self).runtime_child_names if requires_ema_ref else ()
+
+ def _initialize_snapshots(self) -> None:
+ """Initialize the optional old-policy snapshot before exact state resume."""
+ if not self._algorithm_runtime_child_names():
+ return
+ training_args: DGPOTrainingArguments = self.training_args # type: ignore[assignment]
+ ema_ref_device = (
+ self.accelerator.device
+ if training_args.ema_ref_device == "cuda"
+ else torch.device("cpu")
+ )
+ self.adapter.add_named_parameters(
+ "ema_ref",
+ device=ema_ref_device,
+ overwrite=True,
+ )
+ self._register_named_parameter_runtime_child("ema_ref")
+ logger.info(
+ f"Initialized old-policy EMA ref on {ema_ref_device} "
+ f"(max_decay={training_args.ema_ref_max_decay}, "
+ f"ramp_rate={training_args.ema_ref_ramp_rate})."
+ )
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -174,19 +205,6 @@ def __init__(self, **kwargs):
self.ema_ref_max_decay = ta.ema_ref_max_decay
self.ema_ref_ramp_rate = ta.ema_ref_ramp_rate
self._requires_ema_ref = self.clip_dsm or self.clip_kl or self.use_ema_ref
- if self._requires_ema_ref:
- ema_ref_device = (
- self.accelerator.device if ta.ema_ref_device == "cuda" else torch.device("cpu")
- )
- self.adapter.add_named_parameters(
- "ema_ref",
- device=ema_ref_device,
- overwrite=True,
- )
- logger.info(
- f"Initialized old-policy EMA ref on {ema_ref_device} "
- f"(max_decay={self.ema_ref_max_decay}, ramp_rate={self.ema_ref_ramp_rate})."
- )
# =========================== Properties ============================
@property
diff --git a/tests/models/test_variant_checkpointing.py b/tests/models/test_variant_checkpointing.py
index ded2c983e..3540c334f 100644
--- a/tests/models/test_variant_checkpointing.py
+++ b/tests/models/test_variant_checkpointing.py
@@ -31,6 +31,13 @@
ComponentVariantRegistry,
)
from flow_factory.trainers.abc import BaseTrainer
+from flow_factory.trainers.common.runtime_state import TrainerRuntimeState
+from flow_factory.trainers.distillation.tdm_r1 import (
+ SLOW_SURROGATE_SNAPSHOT,
+ TDMR1Trainer,
+)
+from flow_factory.trainers.execution import TrainingProgress
+from flow_factory.trainers.multirole import MULTIROLE_RUNTIME_CHILD_NAME
from flow_factory.trainers.role_optimization import (
OptimizationRole,
RoleOptimizationCoordinator,
@@ -403,6 +410,63 @@ def test_registered_custom_state_defensively_revalidates_metadata() -> None:
trainer._load_multirole_state_dict(state)
+def test_multirole_child_rejects_runtime_progress_mismatch_before_load() -> None:
+ """The primary-role counter cannot overwrite the runtime progress truth."""
+ trainer = _trainer_runtime("full")
+ trainer._register_multirole_checkpointing()
+ state = trainer._multirole_state_dict()
+
+ with pytest.raises(ValueError, match="optimizer_step=3.*trainer_step=0"):
+ trainer._multirole_checkpoint_state.validate_runtime_progress(
+ TrainingProgress(optimizer_step=3),
+ state,
+ )
+
+
+def test_safe_runtime_round_trip_replaces_accelerate_pickle_custom_state(
+ tmp_path: Path,
+) -> None:
+ """Real Accelerator state restores multi-role counters through safetensors."""
+ checkpoint = tmp_path / "checkpoint"
+ source = _trainer_runtime("full")
+ source.runtime_state = TrainerRuntimeState(child_names=(MULTIROLE_RUNTIME_CHILD_NAME,))
+ source._runtime_children_attached = False
+ source._register_multirole_checkpointing()
+ source.runtime_state.attach_child(
+ MULTIROLE_RUNTIME_CHILD_NAME,
+ source._multirole_checkpoint_state,
+ )
+ source._runtime_children_attached = True
+ _step_fake_role(source)
+ source.optimization_roles[BASE_VARIANT].step = 7
+ source.step = 7
+
+ source._save_exact_training_state(str(checkpoint))
+
+ assert not tuple(checkpoint.glob("custom_checkpoint_*.pkl"))
+ assert (checkpoint / "flow_factory_trainer_runtime.json").is_file()
+
+ target = _trainer_runtime("full")
+ target.runtime_state = TrainerRuntimeState(child_names=(MULTIROLE_RUNTIME_CHILD_NAME,))
+ target._runtime_children_attached = False
+ target._register_multirole_checkpointing()
+ children = {
+ MULTIROLE_RUNTIME_CHILD_NAME: target._multirole_checkpoint_state,
+ }
+ target.runtime_state.validate_load(
+ checkpoint,
+ children=children,
+ invariant_validator=target._validate_runtime_checkpoint_invariants,
+ )
+ target.adapter.load_checkpoint(str(checkpoint), resume_type="state")
+ target.runtime_state.commit_validated_load()
+ target._attach_runtime_children(children)
+
+ assert target.step == 7
+ assert target.optimization_roles[BASE_VARIANT].step == 7
+ assert target.optimization_roles["fake"].step == 1
+
+
def test_canonical_save_writes_metadata_before_accelerate_mutates_state(tmp_path: Path) -> None:
trainer = _trainer_runtime("full")
trainer._register_multirole_checkpointing()
@@ -608,6 +672,42 @@ def test_snapshot_state_round_trip_is_exact_and_not_export_metadata() -> None:
assert "old_surrogate" not in str(registry.metadata())
+def test_tdm_r1_predeclares_the_slow_snapshot_for_exact_resume_preflight() -> None:
+ """A fresh resume target exposes the same snapshot schema as its source."""
+ source_base = _trainer_runtime(
+ "full",
+ variant_names=("generator", "fake", "surrogate"),
+ )
+ source = object.__new__(TDMR1Trainer)
+ source.__dict__.update(source_base.__dict__)
+ source._initialize_snapshots()
+ source_registry = source.adapter.component_variant_registry
+ for parameter in source_registry.parameters("surrogate"):
+ parameter.data.add_(2)
+ source_registry.update_snapshot(SLOW_SURROGATE_SNAPSHOT, decay=0.25)
+ state = source._multirole_snapshot_state_dict()
+
+ target_base = _trainer_runtime(
+ "full",
+ variant_names=("generator", "fake", "surrogate"),
+ )
+ target = object.__new__(TDMR1Trainer)
+ target.__dict__.update(target_base.__dict__)
+ target._initialize_snapshots()
+
+ target._validate_multirole_snapshot_state(state)
+ target.adapter.component_variant_registry.load_snapshot_state_dict(state)
+ actual = target.adapter.component_variant_registry.snapshot_state_dict()
+ assert actual["update_counts"] == {SLOW_SURROGATE_SNAPSHOT: 1}
+ for name, expected in state["snapshots"][SLOW_SURROGATE_SNAPSHOT]["parameters"].items():
+ torch.testing.assert_close(
+ actual["snapshots"][SLOW_SURROGATE_SNAPSHOT]["parameters"][name],
+ expected,
+ rtol=0,
+ atol=0,
+ )
+
+
def test_accelerate_round_trip_restores_the_old_surrogate_snapshot(tmp_path: Path) -> None:
trainer = _trainer_runtime("full", variant_names=(BASE_VARIANT, "fake", "surrogate"))
registry = trainer.adapter.component_variant_registry
diff --git a/tests/trainers/test_base_trainer_epoch_contract.py b/tests/trainers/test_base_trainer_epoch_contract.py
index 2dc76e487..6539218cf 100644
--- a/tests/trainers/test_base_trainer_epoch_contract.py
+++ b/tests/trainers/test_base_trainer_epoch_contract.py
@@ -17,7 +17,9 @@
from typing import Any, Iterator, List
import pytest
+import torch
+from flow_factory.contracts.execution import OFFLINE_EXECUTION_CONTRACT
from flow_factory.trainers.abc import BaseTrainer
@@ -67,11 +69,17 @@ def sampling_context(self) -> Iterator[None]:
yield
self.events.append("scope:exit")
- def _after_optimizer_step(self) -> None:
+ def _after_acquisition_cycle(self) -> None:
"""Record an algorithm-owned auxiliary update."""
self.events.append("after_step")
+class OfflineEpochTrainerFake(EpochTrainerFake):
+ """Expose dataset-acquisition boundary semantics without a real loader."""
+
+ execution_contract = OFFLINE_EXECUTION_CONTRACT
+
+
def test_shared_epoch_runs_the_stages_in_order() -> None:
"""Every algorithm inherits one reseed, sample, feedback, optimize, EMA sequence."""
trainer = EpochTrainerFake(total_epochs=2)
@@ -121,6 +129,50 @@ def test_on_policy_sampling_needs_no_scope_override() -> None:
assert trainer.events == []
+def test_offline_checkpoint_captures_post_eval_rng_state(tmp_path: Any) -> None:
+ """Offline save dispatch occurs after evaluation advances exact-state RNG."""
+ trainer = object.__new__(OfflineEpochTrainerFake)
+ trainer.epoch = 1
+ trainer.log_args = SimpleNamespace(save_freq=1, save_dir=str(tmp_path), run_name="run")
+ trainer.eval_args = SimpleNamespace(eval_freq=1)
+ events: List[str] = []
+ post_eval_rng: List[torch.Tensor] = []
+ saved_rng: List[torch.Tensor] = []
+
+ def evaluate() -> None:
+ events.append("eval")
+ torch.rand(1)
+ post_eval_rng.append(torch.get_rng_state().clone())
+
+ def save_checkpoint(save_directory: str, *, epoch: int) -> None:
+ events.append("save")
+ saved_rng.append(torch.get_rng_state().clone())
+
+ trainer.evaluate = evaluate
+ trainer.save_checkpoint = save_checkpoint
+ torch.manual_seed(1234)
+
+ trainer._run_periodic_cycle_boundaries()
+
+ assert events == ["eval", "save"]
+ assert torch.equal(saved_rng[0], post_eval_rng[0])
+
+
+def test_online_boundary_keeps_save_before_evaluation(tmp_path: Any) -> None:
+ """Generated acquisition retains the established pre-rollout save cadence."""
+ trainer = object.__new__(EpochTrainerFake)
+ trainer.epoch = 1
+ trainer.log_args = SimpleNamespace(save_freq=1, save_dir=str(tmp_path), run_name="run")
+ trainer.eval_args = SimpleNamespace(eval_freq=1)
+ events: List[str] = []
+ trainer.save_checkpoint = lambda *args, **kwargs: events.append("save")
+ trainer.evaluate = lambda: events.append("eval")
+
+ trainer._run_periodic_cycle_boundaries()
+
+ assert events == ["save", "eval"]
+
+
def test_coupled_paradigm_rejects_ode_dynamics() -> None:
"""A coupled algorithm on ODE dynamics has no transition density to differentiate."""
trainer = object.__new__(EpochTrainerFake)
diff --git a/tests/trainers/test_distillation_metrics.py b/tests/trainers/test_distillation_metrics.py
index 7447f4bec..57bfcf07f 100644
--- a/tests/trainers/test_distillation_metrics.py
+++ b/tests/trainers/test_distillation_metrics.py
@@ -16,21 +16,30 @@
from __future__ import annotations
+import random
from contextlib import contextmanager
from types import SimpleNamespace
from typing import Any, Dict, Iterator
+import numpy as np
import pytest
import torch
+from torch.utils.data import DataLoader, Dataset
+from flow_factory.data_utils.multi_source import (
+ MultiSourceTrainDataLoader,
+ WeightedSourceBatchScheduler,
+)
+from flow_factory.data_utils.sampler import DistributedKRepeatSampler
from flow_factory.trainers.distillation.distillation_runtime import (
+ generate_one_rollout_batch,
pop_distillation_metrics,
record_distillation_metric,
record_state_statistics,
- generate_one_rollout_batch,
run_distillation_training_step,
run_role_phase,
)
+from flow_factory.trainers.execution import TrainingProgress
class SingleRankAccelerator:
@@ -63,6 +72,201 @@ def _null_context() -> Iterator[None]:
yield
+class _InfiniteEpochBatchSampler:
+ """Expose the epoch controls used by the online grouped samplers."""
+
+ num_batches_per_epoch = 3
+
+ def __init__(self) -> None:
+ self.epoch = 0
+ self.set_epoch_calls: list[int] = []
+
+ def set_epoch(self, epoch: int) -> None:
+ self.epoch = epoch
+ self.set_epoch_calls.append(epoch)
+
+
+class _InfiniteGroupedLoader:
+ """Yield finite epoch blocks through one never-ending iterator."""
+
+ def __init__(self) -> None:
+ self.batch_sampler = _InfiniteEpochBatchSampler()
+
+ def __iter__(self) -> Iterator[tuple[int, int]]:
+ while True:
+ epoch = self.batch_sampler.epoch
+ for batch_offset in range(self.batch_sampler.num_batches_per_epoch):
+ yield epoch, batch_offset
+ self.batch_sampler.epoch += 1
+
+
+class _RandomizedCursorDataset(Dataset):
+ """Expose parent-process RNG draws made by one real DataLoader fetch."""
+
+ def __len__(self) -> int:
+ return 3
+
+ def __getitem__(self, index: int) -> Dict[str, Any]:
+ return {
+ "index": index,
+ "python_draw": random.random(),
+ "numpy_draw": float(np.random.random()),
+ "torch_draw": torch.rand(()),
+ }
+
+
+class _ValueDataset(Dataset):
+ """Return deterministic dictionary rows accepted by the multi-source wrapper."""
+
+ def __init__(self, values: list[int]) -> None:
+ self.values = values
+
+ def __len__(self) -> int:
+ return len(self.values)
+
+ def __getitem__(self, index: int) -> Dict[str, torch.Tensor]:
+ return {"value": torch.tensor(self.values[index])}
+
+
+def _rollout_cursor_trainer(progress: TrainingProgress) -> SimpleNamespace:
+ """Build the one-batch rollout surface around an infinite grouped loader."""
+ return SimpleNamespace(
+ progress=progress,
+ training_args=SimpleNamespace(
+ gradient_accumulation_steps=2,
+ num_batches_per_epoch=3,
+ ),
+ dataloader=_InfiniteGroupedLoader(),
+ adapter=SimpleNamespace(rollout=lambda: None),
+ _rollout_acceleration=_null_context,
+ autocast=_null_context,
+ sample_batch=lambda batch, **kwargs: [batch],
+ _rollout_data_iter=None,
+ _rollout_batches_consumed=None,
+ )
+
+
+def _real_rollout_cursor_trainer(
+ progress: TrainingProgress,
+ *,
+ use_explicit_generator: bool,
+) -> SimpleNamespace:
+ """Build a real infinite grouped DataLoader with observable RNG fetches."""
+ dataset = _RandomizedCursorDataset()
+ sampler = DistributedKRepeatSampler(
+ dataset,
+ batch_size=1,
+ group_size=1,
+ unique_sample_num=3,
+ num_replicas=1,
+ rank=0,
+ seed=17,
+ )
+ loader_generator = torch.Generator().manual_seed(91) if use_explicit_generator else None
+ dataloader = DataLoader(
+ dataset,
+ batch_sampler=sampler,
+ generator=loader_generator,
+ )
+
+ def sample_batch(batch: Dict[str, torch.Tensor], **kwargs: Any) -> list[tuple]:
+ del kwargs
+ return [
+ (
+ int(batch["index"].item()),
+ float(batch["python_draw"].item()),
+ float(batch["numpy_draw"].item()),
+ float(batch["torch_draw"].item()),
+ )
+ ]
+
+ return SimpleNamespace(
+ progress=progress,
+ training_args=SimpleNamespace(
+ gradient_accumulation_steps=2,
+ num_batches_per_epoch=3,
+ ),
+ dataloader=dataloader,
+ adapter=SimpleNamespace(rollout=lambda: None),
+ _rollout_acceleration=_null_context,
+ autocast=_null_context,
+ sample_batch=sample_batch,
+ _rollout_data_iter=None,
+ _rollout_batches_consumed=None,
+ )
+
+
+def _source_loader(
+ values: list[int],
+ *,
+ seed: int,
+ use_explicit_generator: bool = False,
+) -> DataLoader:
+ dataset = _ValueDataset(values)
+ sampler = DistributedKRepeatSampler(
+ dataset,
+ batch_size=1,
+ group_size=1,
+ unique_sample_num=len(dataset),
+ num_replicas=1,
+ rank=0,
+ seed=seed,
+ )
+ generator = torch.Generator().manual_seed(seed + 1000) if use_explicit_generator else None
+ return DataLoader(dataset, batch_sampler=sampler, generator=generator)
+
+
+def _finite_multi_source_loader(
+ *,
+ use_explicit_generators: bool = False,
+) -> MultiSourceTrainDataLoader:
+ loaders = {
+ "a": _source_loader(
+ [10],
+ seed=11,
+ use_explicit_generator=use_explicit_generators,
+ ),
+ "b": _source_loader(
+ [20, 21],
+ seed=13,
+ use_explicit_generator=use_explicit_generators,
+ ),
+ }
+ return MultiSourceTrainDataLoader(
+ loaders,
+ WeightedSourceBatchScheduler({"a": 1, "b": 2}, seed=17),
+ batch_size=1,
+ )
+
+
+def _finite_multi_source_trainer(
+ progress: TrainingProgress,
+ *,
+ accumulation_steps: int,
+) -> SimpleNamespace:
+ def sample_batch(batch: Dict[str, Any], **kwargs: Any) -> list[tuple[str, int]]:
+ del kwargs
+ return [(batch["__source__"][0], int(batch["value"].item()))]
+
+ return SimpleNamespace(
+ progress=progress,
+ training_args=SimpleNamespace(
+ gradient_accumulation_steps=accumulation_steps,
+ # DMD2 validates the global geometry before its per-source samplers
+ # independently round their quotas. The finite wrapper length is the
+ # authoritative result when those two values differ.
+ num_batches_per_epoch=2,
+ ),
+ dataloader=_finite_multi_source_loader(),
+ adapter=SimpleNamespace(rollout=lambda: None),
+ _rollout_acceleration=_null_context,
+ autocast=_null_context,
+ sample_batch=sample_batch,
+ _rollout_data_iter=None,
+ _rollout_batches_consumed=None,
+ )
+
+
def _trainer(accelerator: Any = None) -> SimpleNamespace:
"""Build the minimal trainer surface the metric buffer touches."""
return SimpleNamespace(accelerator=accelerator or SingleRankAccelerator())
@@ -376,6 +580,212 @@ def add_samples(self, samples: list) -> None:
]
+def test_exact_resume_reconstructs_infinite_grouped_rollout_cursor() -> None:
+ """A resumed distillation run must not restart its prompt sampler at batch zero."""
+ uninterrupted = _rollout_cursor_trainer(TrainingProgress())
+ observed = []
+ for rollout_iteration in range(2):
+ uninterrupted.progress = TrainingProgress(rollout_iteration=rollout_iteration)
+ for _ in range(2):
+ observed.extend(
+ generate_one_rollout_batch(
+ uninterrupted,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+ )
+ uninterrupted.progress = TrainingProgress(rollout_iteration=2)
+ expected_next = generate_one_rollout_batch(
+ uninterrupted,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+
+ resumed = _rollout_cursor_trainer(TrainingProgress(rollout_iteration=2))
+ resumed_next = generate_one_rollout_batch(
+ resumed,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+
+ assert observed == [(0, 0), (0, 1), (0, 2), (1, 0)]
+ assert expected_next == [(1, 1)]
+ assert resumed_next == expected_next
+ assert resumed.dataloader.batch_sampler.set_epoch_calls == [1]
+
+
+def test_exact_resume_uses_gas_to_reconstruct_rollout_batch_count() -> None:
+ """Completed rollout iterations expand to the exact number of consumed batches."""
+ resumed = _rollout_cursor_trainer(TrainingProgress(rollout_iteration=4))
+
+ next_batch = generate_one_rollout_batch(
+ resumed,
+ reward_buffer=None,
+ algorithm_name="TDM",
+ )
+
+ assert next_batch == [(2, 2)]
+ assert resumed._rollout_batches_consumed == 9
+ assert resumed.dataloader.batch_sampler.set_epoch_calls == [2]
+
+
+@pytest.mark.parametrize("use_explicit_generator", [False, True])
+def test_exact_resume_real_dataloader_preserves_rng_state(
+ use_explicit_generator: bool,
+) -> None:
+ """Iterator reconstruction and skipped fetches must be RNG-neutral."""
+ random.seed(123)
+ np.random.seed(123)
+ torch.manual_seed(123)
+ uninterrupted = _real_rollout_cursor_trainer(
+ TrainingProgress(),
+ use_explicit_generator=use_explicit_generator,
+ )
+ explicit_generator = uninterrupted.dataloader.generator
+ explicit_initial_state = (
+ explicit_generator.get_state().clone() if explicit_generator is not None else None
+ )
+ for rollout_iteration in range(2):
+ uninterrupted.progress = TrainingProgress(rollout_iteration=rollout_iteration)
+ for _ in range(2):
+ generate_one_rollout_batch(
+ uninterrupted,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+ uninterrupted.progress = TrainingProgress(rollout_iteration=2)
+ python_state = random.getstate()
+ numpy_state = np.random.get_state()
+ torch_state = torch.random.get_rng_state()
+ expected_next = generate_one_rollout_batch(
+ uninterrupted,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+
+ resumed = _real_rollout_cursor_trainer(
+ TrainingProgress(rollout_iteration=2),
+ use_explicit_generator=use_explicit_generator,
+ )
+ random.setstate(python_state)
+ np.random.set_state(numpy_state)
+ torch.random.set_rng_state(torch_state)
+ resumed_next = generate_one_rollout_batch(
+ resumed,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+
+ assert resumed_next == expected_next
+ if explicit_initial_state is not None:
+ assert torch.equal(uninterrupted.dataloader.generator.get_state(), explicit_initial_state)
+ assert torch.equal(resumed.dataloader.generator.get_state(), explicit_initial_state)
+
+
+def test_finite_multi_source_rollover_uses_actual_loader_length() -> None:
+ """A declared-geometry drift must not skip the first batch of the next epoch."""
+ trainer = _finite_multi_source_trainer(
+ TrainingProgress(),
+ accumulation_steps=1,
+ )
+ assert len(trainer.dataloader) == 3
+ for _ in range(len(trainer.dataloader)):
+ generate_one_rollout_batch(
+ trainer,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+ actual_next = generate_one_rollout_batch(
+ trainer,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+
+ reference = _finite_multi_source_loader()
+ reference.set_epoch(1)
+ expected_batch = next(iter(reference))
+ expected_next = [(expected_batch["__source__"][0], int(expected_batch["value"].item()))]
+
+ assert actual_next == expected_next
+ assert trainer.dataloader._scheduler._epoch == 1
+
+
+@pytest.mark.parametrize("use_explicit_generators", [False, True])
+def test_finite_multi_source_lazy_iterators_are_rng_neutral_at_zero_offset(
+ use_explicit_generators: bool,
+) -> None:
+ """The wrapper's lazy child iterators must initialize inside the RNG scope."""
+ trainer = _finite_multi_source_trainer(
+ TrainingProgress(),
+ accumulation_steps=1,
+ )
+ if use_explicit_generators:
+ trainer.dataloader = _finite_multi_source_loader(use_explicit_generators=True)
+ random.seed(31)
+ np.random.seed(31)
+ torch.manual_seed(31)
+ python_state = random.getstate()
+ numpy_state = np.random.get_state()
+ torch_state = torch.random.get_rng_state()
+ generator_states = {
+ name: loader.generator.get_state().clone()
+ for name, loader in trainer.dataloader.dataloaders_by_source.items()
+ if loader.generator is not None
+ }
+
+ generate_one_rollout_batch(
+ trainer,
+ reward_buffer=None,
+ algorithm_name="DMD2",
+ )
+
+ assert random.getstate() == python_state
+ actual_numpy_state = np.random.get_state()
+ assert actual_numpy_state[0] == numpy_state[0]
+ np.testing.assert_array_equal(actual_numpy_state[1], numpy_state[1])
+ assert actual_numpy_state[2:] == numpy_state[2:]
+ assert torch.equal(torch.random.get_rng_state(), torch_state)
+ for name, expected_state in generator_states.items():
+ actual_generator = trainer.dataloader.dataloaders_by_source[name].generator
+ assert torch.equal(actual_generator.get_state(), expected_state)
+
+
+def test_exact_resume_finite_multi_source_crosses_epoch_with_gas() -> None:
+ """Progress times GAS maps through the finite wrapper's actual epoch size."""
+ uninterrupted = _finite_multi_source_trainer(
+ TrainingProgress(),
+ accumulation_steps=2,
+ )
+ for rollout_iteration in range(2):
+ uninterrupted.progress = TrainingProgress(rollout_iteration=rollout_iteration)
+ for _ in range(2):
+ generate_one_rollout_batch(
+ uninterrupted,
+ reward_buffer=None,
+ algorithm_name="TDM",
+ )
+ uninterrupted.progress = TrainingProgress(rollout_iteration=2)
+ expected_next = generate_one_rollout_batch(
+ uninterrupted,
+ reward_buffer=None,
+ algorithm_name="TDM",
+ )
+
+ resumed = _finite_multi_source_trainer(
+ TrainingProgress(rollout_iteration=2),
+ accumulation_steps=2,
+ )
+ resumed_next = generate_one_rollout_batch(
+ resumed,
+ reward_buffer=None,
+ algorithm_name="TDM",
+ )
+
+ assert resumed_next == expected_next
+ assert resumed._rollout_batches_consumed == 5
+ assert resumed.dataloader._scheduler._epoch == 1
+
+
def test_an_epoch_that_records_nothing_logs_nothing() -> None:
"""An empty log call would still stamp a step and clutter the run's history."""
logged: list = []
diff --git a/tests/trainers/test_dmd2.py b/tests/trainers/test_dmd2.py
index 285771fd6..5e698bc9b 100644
--- a/tests/trainers/test_dmd2.py
+++ b/tests/trainers/test_dmd2.py
@@ -68,7 +68,7 @@ def sample_ode_step_index(self, draw_index: int) -> int:
trainer.role_optimization = _FakeCoordinator()
trainer.dataloader = None
trainer._rollout_data_iter = None
- trainer._rollout_dataloader_epoch = 0
+ trainer._rollout_batches_consumed = None
trainer.step = 0
trainer.epoch = 0
return trainer
diff --git a/tests/trainers/test_execution_kernel.py b/tests/trainers/test_execution_kernel.py
index 808e76002..8426014dd 100644
--- a/tests/trainers/test_execution_kernel.py
+++ b/tests/trainers/test_execution_kernel.py
@@ -208,6 +208,7 @@ def test_failed_dataset_batch_does_not_publish_a_partial_epoch() -> None:
assert trainer.events == ["set_epoch:0", "batch:[0, 1]", "batch:[2, 3]"]
assert trainer.progress == TrainingProgress(optimizer_step=1)
+ assert trainer._acquisition_cycle_incomplete is True
def test_dataset_driver_requires_official_distributed_sampler_on_one_process() -> None:
diff --git a/tests/trainers/test_runtime_checkpoint_integration.py b/tests/trainers/test_runtime_checkpoint_integration.py
new file mode 100644
index 000000000..11b39b49c
--- /dev/null
+++ b/tests/trainers/test_runtime_checkpoint_integration.py
@@ -0,0 +1,858 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Integration tests for trainer-owned safe exact-resume lifecycle."""
+
+import json
+import random
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+import pytest
+import torch
+from accelerate.utils import DistributedType
+
+from flow_factory.trainers.abc import BaseTrainer
+from flow_factory.trainers.common.runtime_state import (
+ TRAINER_RUNTIME_METADATA_FILENAME,
+ TrainerRuntimeState,
+)
+from flow_factory.trainers.execution import TrainingProgress
+
+
+def _identity(*, world_size: int = 1) -> dict[str, Any]:
+ """Return one strict realized-runtime identity for file tests."""
+ return {
+ "trainer": "tests.TinyTrainer",
+ "adapter": "tests.TinyAdapter",
+ "algorithm": "sft",
+ "model": "tiny:tests/tiny",
+ "finetune_type": "full",
+ "optimizer_roles": ("base",),
+ "parameter_schema_digest": "a" * 64,
+ "optimizer_schema_digest": "b" * 64,
+ "execution_contract_digest": "d" * 64,
+ "data_contract_digest": "e" * 64,
+ "distributed_type": "NO",
+ "backend_schema_digest": "c" * 64,
+ "mixed_precision": "no",
+ "gradient_scaler": "none",
+ "world_size": world_size,
+ }
+
+
+def _write_accelerate_artifacts(directory: Path) -> None:
+ """Write the minimal exact-resume artifact names validated by runtime state."""
+ directory.mkdir(parents=True, exist_ok=True)
+ (directory / "model.safetensors").write_bytes(b"prepared-model")
+ torch.save({"state": {}, "param_groups": []}, directory / "optimizer.bin")
+ _write_rng_artifact(directory / "random_states_0.pkl")
+
+
+def _write_rng_artifact(path: Path) -> None:
+ """Write one parseable per-rank Accelerate RNG state."""
+ torch.save(
+ {
+ "step": 0,
+ "random_state": random.getstate(),
+ "numpy_random_seed": np.random.get_state(),
+ "torch_manual_seed": torch.get_rng_state(),
+ },
+ path,
+ )
+
+
+class _SaveAccelerator:
+ """Expose the synchronization and publication ownership used by the trainer."""
+
+ def __init__(self) -> None:
+ self.is_main_process = True
+ self.is_local_main_process = True
+ self.device = torch.device("cpu")
+ self.project_configuration = SimpleNamespace(save_on_each_node=False)
+ self.wait_calls = 0
+
+ def wait_for_everyone(self) -> None:
+ """Record each lifecycle barrier."""
+ self.wait_calls += 1
+
+
+class _StateSavingAdapter:
+ """Represent the adapter's Accelerator-artifact save delegation."""
+
+ def __init__(self, *, fail: bool = False) -> None:
+ self.fail = fail
+ self.calls: list[tuple[str, bool, bool]] = []
+
+ def save_checkpoint(
+ self,
+ *,
+ save_directory: str,
+ model_only: bool,
+ include_training_roles: bool,
+ ) -> None:
+ """Write core artifacts before optionally simulating an interrupted save."""
+ self.calls.append((save_directory, model_only, include_training_roles))
+ _write_accelerate_artifacts(Path(save_directory))
+ if self.fail:
+ raise RuntimeError("accelerator save failed")
+
+
+class _StateLoadingAdapter:
+ """Record state mutation and enforce the trainer's preflight ordering."""
+
+ def __init__(self, trainer: Any, checkpoint: Path, *, fail_load: bool = False) -> None:
+ self.trainer = trainer
+ self.checkpoint = checkpoint
+ self.post_init_paths: list[str | None] = []
+ self.load_calls = 0
+ self.core_mutated = False
+ self.fail_load = fail_load
+
+ def post_init(self) -> None:
+ """Observe that the adapter's legacy automatic resume was suppressed."""
+ self.post_init_paths.append(self.trainer.model_args.resume_path)
+
+ def _resolve_checkpoint_path(self, path: str) -> str:
+ """Return the already-local checkpoint directory."""
+ assert path == str(self.checkpoint)
+ return path
+
+ def _load_training_state(self, path: str) -> None:
+ """Mutate only after runtime validation has staged a payload."""
+ assert path == str(self.checkpoint)
+ assert self.trainer.runtime_state.validated_load_pending
+ assert self.trainer.progress == TrainingProgress()
+ self.load_calls += 1
+ self.core_mutated = True
+ if self.fail_load:
+ raise RuntimeError("accelerator load failed")
+
+
+class _RuntimeChild:
+ """Checkpointable child that can fail only during the commit phase."""
+
+ def __init__(self, value: int = 0, *, fail_load: bool = False) -> None:
+ self.value = value
+ self.fail_load = fail_load
+ self.load_calls = 0
+
+ def state_dict(self) -> dict[str, Any]:
+ """Return one scalar child payload."""
+ return {"value": self.value}
+
+ def validate_state_dict(self, state_dict: dict[str, Any]) -> None:
+ """Validate without mutating the child."""
+ if set(state_dict) != {"value"} or type(state_dict["value"]) is not int:
+ raise TypeError(f"invalid runtime child payload: {state_dict!r}")
+
+ def load_state_dict(self, state_dict: dict[str, Any]) -> None:
+ """Restore after core load, optionally simulating a rank-local failure."""
+ self.load_calls += 1
+ if self.fail_load:
+ raise RuntimeError("runtime child load failed")
+ self.value = state_dict["value"]
+
+
+def _saving_trainer(adapter: _StateSavingAdapter) -> Any:
+ """Build the narrow host consumed by BaseTrainer's atomic save helper."""
+ trainer = object.__new__(BaseTrainer)
+ trainer.accelerator = _SaveAccelerator()
+ trainer.adapter = adapter
+ trainer.runtime_state = TrainerRuntimeState(
+ TrainingProgress(optimizer_step=4, rollout_iteration=0, data_epoch=2),
+ identity=_identity(),
+ )
+ return trainer
+
+
+def _loading_trainer(
+ checkpoint: Path,
+ *,
+ fail_load: bool = False,
+ num_processes: int = 1,
+) -> Any:
+ """Build the narrow host consumed by the adapter-finalization resume helper."""
+ trainer = object.__new__(BaseTrainer)
+ trainer.model_args = SimpleNamespace(
+ resume_path=str(checkpoint),
+ resume_type="state",
+ )
+ trainer.runtime_state = TrainerRuntimeState(identity=_identity())
+ trainer._runtime_children_attached = False
+ trainer.accelerator = SimpleNamespace(
+ process_index=0,
+ device=torch.device("cpu"),
+ num_processes=num_processes,
+ )
+ trainer.adapter = _StateLoadingAdapter(trainer, checkpoint, fail_load=fail_load)
+ return trainer
+
+
+def _resume_loading_trainer(trainer: Any) -> None:
+ """Run the constructor's post-init then safe exact-resume phases."""
+ trainer._initialize_adapter_runtime()
+ trainer._finalize_adapter_runtime()
+
+
+def test_progress_property_has_one_runtime_source_of_truth() -> None:
+ """Compatibility counters replace the immutable runtime value without a copy."""
+ trainer = object.__new__(BaseTrainer)
+ trainer.runtime_state = TrainerRuntimeState()
+
+ trainer.step = 5
+ trainer.progress = TrainingProgress(
+ optimizer_step=trainer.step,
+ rollout_iteration=3,
+ data_epoch=2,
+ )
+
+ assert trainer.progress is trainer.runtime_state.progress
+ assert trainer.step == 5
+ assert "progress" not in trainer.__dict__
+
+
+def test_exact_state_save_publishes_only_after_runtime_manifest(tmp_path: Path) -> None:
+ """Accelerator artifacts and runtime manifest become visible as one directory."""
+ final = tmp_path / "checkpoint-2"
+ adapter = _StateSavingAdapter()
+ trainer = _saving_trainer(adapter)
+
+ trainer._save_exact_training_state(str(final))
+
+ staging = tmp_path / ".checkpoint-2.flow-factory-staging"
+ claim = tmp_path / ".checkpoint-2.flow-factory-publish-claim"
+ assert final.is_dir()
+ assert not staging.exists()
+ assert not claim.exists()
+ assert (final / TRAINER_RUNTIME_METADATA_FILENAME).is_file()
+ metadata = json.loads((final / TRAINER_RUNTIME_METADATA_FILENAME).read_text(encoding="utf-8"))
+ assert [entry["path"] for entry in metadata["state_files"]] == [
+ "model.safetensors",
+ "optimizer.bin",
+ "random_states_0.pkl",
+ ]
+ assert adapter.calls == [(str(staging), False, True)]
+ assert trainer.accelerator.wait_calls == 4
+
+
+def test_atomic_publish_claim_elects_one_writer_on_a_shared_path(tmp_path: Path) -> None:
+ """Multiple local-main candidates cannot race the manifest or directory rename."""
+ claim = tmp_path / ".checkpoint.flow-factory-publish-claim"
+ first = _saving_trainer(_StateSavingAdapter())
+ second = _saving_trainer(_StateSavingAdapter())
+
+ assert first._claim_state_checkpoint_publication(str(claim))
+ assert not second._claim_state_checkpoint_publication(str(claim))
+
+
+def test_global_publisher_claim_loss_aborts_before_core_save(tmp_path: Path) -> None:
+ """A concurrent job cannot make this global main write the shared staging path."""
+ adapter = _StateSavingAdapter()
+ trainer = _saving_trainer(adapter)
+ trainer._claim_state_checkpoint_publication = lambda path: False
+
+ with pytest.raises(FileExistsError, match="claimed by a concurrent writer"):
+ trainer._save_exact_training_state(str(tmp_path / "checkpoint"))
+
+ assert adapter.calls == []
+
+
+def test_manifest_failure_keeps_staging_and_claim_without_entering_next_barrier(
+ tmp_path: Path,
+) -> None:
+ """A publisher error exits before peers could wait at the next raw barrier."""
+ final = tmp_path / "checkpoint"
+ trainer = _saving_trainer(_StateSavingAdapter())
+
+ def fail_manifest(path: str) -> None:
+ raise OSError(f"manifest failed at {path}")
+
+ trainer.runtime_state.prepare_save = fail_manifest
+
+ with pytest.raises(OSError, match="manifest failed"):
+ trainer._save_exact_training_state(str(final))
+
+ assert not final.exists()
+ assert (tmp_path / ".checkpoint.flow-factory-staging").is_dir()
+ assert (tmp_path / ".checkpoint.flow-factory-publish-claim").is_file()
+ assert trainer.accelerator.wait_calls == 2
+
+
+def test_remote_publication_failure_keeps_successful_node_claim(
+ tmp_path: Path,
+) -> None:
+ """Node-local finals remain visibly claimed until every publisher succeeds."""
+ final = tmp_path / "checkpoint"
+ trainer = _saving_trainer(_StateSavingAdapter())
+
+ def synchronize(phase: str, error: Exception | None) -> None:
+ if error is not None:
+ raise error
+ if phase == "atomic publication":
+ raise RuntimeError("remote publisher replace failed")
+
+ trainer._synchronize_checkpoint_phase_error = synchronize
+
+ with pytest.raises(RuntimeError, match="remote publisher replace failed"):
+ trainer._save_exact_training_state(str(final))
+
+ assert final.is_dir()
+ assert not (tmp_path / ".checkpoint.flow-factory-staging").exists()
+ assert (tmp_path / ".checkpoint.flow-factory-publish-claim").is_file()
+
+
+def test_interrupted_accelerator_save_never_publishes_partial_destination(
+ tmp_path: Path,
+) -> None:
+ """A failed core-state save leaves only its explicit staging directory."""
+ final = tmp_path / "checkpoint-2"
+ trainer = _saving_trainer(_StateSavingAdapter(fail=True))
+
+ with pytest.raises(RuntimeError, match="accelerator save failed"):
+ trainer._save_exact_training_state(str(final))
+
+ assert not final.exists()
+ assert (tmp_path / ".checkpoint-2.flow-factory-staging").is_dir()
+ assert (tmp_path / ".checkpoint-2.flow-factory-publish-claim").is_file()
+
+
+def test_exact_state_save_refuses_to_overwrite_before_adapter_mutation(
+ tmp_path: Path,
+) -> None:
+ """An existing immutable destination is rejected before any core save call."""
+ final = tmp_path / "checkpoint-2"
+ final.mkdir()
+ adapter = _StateSavingAdapter()
+ trainer = _saving_trainer(adapter)
+
+ with pytest.raises(FileExistsError, match="cannot overwrite"):
+ trainer._save_exact_training_state(str(final))
+
+ assert adapter.calls == []
+
+
+def test_exact_state_save_rejects_partial_acquisition_before_adapter_mutation(
+ tmp_path: Path,
+) -> None:
+ """Offline state cannot claim exact resume while a dataloader epoch is partial."""
+ adapter = _StateSavingAdapter()
+ trainer = _saving_trainer(adapter)
+ trainer._acquisition_cycle_active = True
+
+ with pytest.raises(RuntimeError, match="complete acquisition boundary"):
+ trainer._save_exact_training_state(str(tmp_path / "checkpoint"))
+
+ assert adapter.calls == []
+
+
+def test_exact_state_save_rejects_mps_before_adapter_mutation(tmp_path: Path) -> None:
+ """MPS cannot publish an exact checkpoint that Accelerate cannot restore."""
+ adapter = _StateSavingAdapter()
+ trainer = _saving_trainer(adapter)
+ trainer.accelerator.device = torch.device("mps")
+
+ with pytest.raises(RuntimeError, match="unsupported on MPS.*does not serialize"):
+ trainer._save_exact_training_state(str(tmp_path / "checkpoint"))
+
+ assert adapter.calls == []
+ assert not (tmp_path / ".checkpoint.flow-factory-staging").exists()
+ assert not (tmp_path / ".checkpoint.flow-factory-publish-claim").exists()
+
+
+def test_sharded_auxiliary_state_fails_before_core_checkpoint_mutation(
+ tmp_path: Path,
+) -> None:
+ """FSDP EMA shards are never serialized as if rank-zero tensors were replicated."""
+ adapter = _StateSavingAdapter()
+ adapter.component_variant_registry = SimpleNamespace(_snapshots={})
+ trainer = _saving_trainer(adapter)
+ trainer.accelerator.distributed_type = DistributedType.FSDP
+ trainer.runtime_state = TrainerRuntimeState(
+ child_names=(trainer._ADAPTER_EMA_RUNTIME_CHILD,),
+ identity=_identity(),
+ )
+
+ with pytest.raises(RuntimeError, match="distributed-aware gather.*adapter_ema"):
+ trainer._save_exact_training_state(str(tmp_path / "checkpoint"))
+
+ assert adapter.calls == []
+
+
+def test_untracked_named_snapshot_fails_before_core_checkpoint_mutation(
+ tmp_path: Path,
+) -> None:
+ """An exact checkpoint cannot silently omit an algorithm-owned reference."""
+ adapter = _StateSavingAdapter()
+ adapter._named_parameters = {
+ "old_policy": SimpleNamespace(ema_wrapper=object()),
+ }
+ trainer = _saving_trainer(adapter)
+
+ with pytest.raises(RuntimeError, match="omit.*old_policy.*runtime_child_names"):
+ trainer._save_exact_training_state(str(tmp_path / "checkpoint"))
+
+ assert adapter.calls == []
+
+
+def test_exact_resume_preflights_then_loads_then_commits_progress(tmp_path: Path) -> None:
+ """Runtime progress changes only after the core loader returns successfully."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source = TrainerRuntimeState(
+ TrainingProgress(optimizer_step=9, rollout_iteration=4, data_epoch=0),
+ identity=_identity(),
+ )
+ source.prepare_save(checkpoint)
+ trainer = _loading_trainer(checkpoint)
+ commit_calls = 0
+ commit = trainer.runtime_state.commit_validated_load
+
+ def commit_once() -> None:
+ nonlocal commit_calls
+ commit_calls += 1
+ commit()
+
+ trainer.runtime_state.commit_validated_load = commit_once
+
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.post_init_paths == [None]
+ assert trainer.adapter.load_calls == 1
+ assert trainer.adapter.core_mutated
+ assert trainer.progress == TrainingProgress(
+ optimizer_step=9,
+ rollout_iteration=4,
+ data_epoch=0,
+ )
+ assert trainer.runtime_state.load_received
+ assert trainer._runtime_children_attached
+ assert commit_calls == 1
+
+
+def test_online_exact_resume_skips_duplicate_source_checkpoint_save(tmp_path: Path) -> None:
+ """The resumed pre-rollout boundary evaluates without rewriting checkpoint N."""
+ checkpoint = tmp_path / "run" / "checkpoints" / "checkpoint-4"
+ _write_accelerate_artifacts(checkpoint)
+ TrainerRuntimeState(
+ TrainingProgress(optimizer_step=9, rollout_iteration=4),
+ identity=_identity(),
+ ).prepare_save(checkpoint)
+ trainer = _loading_trainer(checkpoint)
+ _resume_loading_trainer(trainer)
+ trainer.log_args = SimpleNamespace(
+ save_freq=1,
+ save_dir=str(tmp_path),
+ run_name="run",
+ )
+ trainer.eval_args = SimpleNamespace(eval_freq=1)
+ events: list[str] = []
+ trainer.save_checkpoint = lambda *args, **kwargs: pytest.fail(
+ f"duplicate save attempted: {args!r}, {kwargs!r}"
+ )
+ trainer.evaluate = lambda: events.append("eval")
+
+ trainer._run_periodic_cycle_boundaries()
+
+ assert events == ["eval"]
+
+
+def test_online_exact_resume_does_not_skip_same_basename_at_different_path(
+ tmp_path: Path,
+) -> None:
+ """Duplicate suppression compares resolved full paths and propagates save errors."""
+ trainer = object.__new__(BaseTrainer)
+ trainer.progress = TrainingProgress(rollout_iteration=4)
+ trainer.log_args = SimpleNamespace(
+ save_freq=1,
+ save_dir=str(tmp_path / "new-output"),
+ run_name="run",
+ )
+ trainer.eval_args = SimpleNamespace(eval_freq=1)
+ trainer._exact_resume_source_checkpoint = trainer._canonical_checkpoint_path(
+ str(tmp_path / "old-output" / "run" / "checkpoints" / "checkpoint-4")
+ )
+ trainer._exact_resume_boundary_pending = True
+
+ def fail_save(*args: Any, **kwargs: Any) -> None:
+ raise FileExistsError("different destination already exists")
+
+ trainer.save_checkpoint = fail_save
+ trainer.evaluate = lambda: pytest.fail("evaluation must not run after save failure")
+
+ with pytest.raises(FileExistsError, match="different destination already exists"):
+ trainer._run_periodic_cycle_boundaries()
+
+ assert trainer._exact_resume_boundary_pending
+
+
+def test_remote_rank_preflight_failure_aborts_before_local_core_load(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """All ranks leave together when another rank cannot validate its RNG artifact."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ TrainerRuntimeState(identity=_identity()).prepare_save(checkpoint)
+ trainer = _loading_trainer(checkpoint, num_processes=2)
+ remote_failure = {
+ "rank": 1,
+ "type": "RuntimeError",
+ "message": "missing random_states_1.pkl",
+ }
+ monkeypatch.setattr(
+ "flow_factory.trainers.abc.gather_object",
+ lambda payload: [None, remote_failure],
+ )
+
+ with pytest.raises(RuntimeError, match="resume preflight failed across ranks"):
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.load_calls == 0
+ assert not trainer.adapter.core_mutated
+ assert not trainer.runtime_state.load_received
+
+
+def test_remote_exact_state_resolution_has_no_adapter_barrier(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Remote state resolution defers synchronization to the all-rank preflight."""
+ calls: list[tuple[str, str | None, str | None]] = []
+
+ def download(repo_id: str, subfolder: str | None, revision: str | None) -> str:
+ calls.append((repo_id, subfolder, revision))
+ return "/local/checkpoint"
+
+ monkeypatch.setattr("flow_factory.trainers.abc.download_hf_checkpoint", download)
+
+ assert (
+ BaseTrainer._resolve_exact_state_checkpoint_path("hf://owner/repo/state@revision")
+ == "/local/checkpoint"
+ )
+ assert calls == [("owner/repo", "state", "revision")]
+
+
+def test_remote_rank_core_load_failure_prevents_local_runtime_commit(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Runtime progress commits only after every rank reports a successful core load."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ TrainerRuntimeState(
+ TrainingProgress(optimizer_step=9, data_epoch=3),
+ identity=_identity(),
+ ).prepare_save(checkpoint)
+ trainer = _loading_trainer(checkpoint, num_processes=2)
+ gathered = iter(
+ (
+ [None, None],
+ [
+ None,
+ {
+ "rank": 1,
+ "type": "RuntimeError",
+ "message": "accelerator load failed",
+ },
+ ],
+ )
+ )
+ monkeypatch.setattr(
+ "flow_factory.trainers.abc.gather_object",
+ lambda payload: next(gathered),
+ )
+
+ with pytest.raises(RuntimeError, match="Accelerator artifact load failed across ranks"):
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.load_calls == 1
+ assert trainer.adapter.core_mutated
+ assert trainer.progress == TrainingProgress()
+ assert trainer.runtime_state.validated_load_pending
+ assert not trainer.runtime_state.load_received
+
+
+def test_remote_rank_runtime_child_failure_synchronizes_after_every_core_load(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """No rank returns from finalization when another rank cannot attach a child."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source_child = _RuntimeChild(7)
+ source = TrainerRuntimeState(
+ TrainingProgress(optimizer_step=9, data_epoch=3),
+ child_names=("algorithm_state",),
+ identity=_identity(),
+ )
+ source.attach_child("algorithm_state", source_child)
+ source.prepare_save(checkpoint)
+
+ trainer = _loading_trainer(checkpoint, num_processes=2)
+ target_child = _RuntimeChild()
+ trainer.runtime_state = TrainerRuntimeState(
+ child_names=("algorithm_state",),
+ identity=_identity(),
+ )
+ trainer._trainer_runtime_children = {"algorithm_state": target_child}
+ gathered = iter(
+ (
+ [None, None],
+ [None, None],
+ [
+ None,
+ {
+ "rank": 1,
+ "type": "RuntimeError",
+ "message": "runtime child load failed",
+ },
+ ],
+ )
+ )
+ monkeypatch.setattr(
+ "flow_factory.trainers.abc.gather_object",
+ lambda payload: next(gathered),
+ )
+
+ with pytest.raises(RuntimeError, match="runtime child commit failed across ranks"):
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.load_calls == 1
+ assert trainer.adapter.core_mutated
+ assert target_child.load_calls == 1
+ assert getattr(trainer, "_exact_resume_source_checkpoint", None) is None
+ assert not getattr(trainer, "_exact_resume_boundary_pending", False)
+
+
+@pytest.mark.parametrize(
+ "identity_field",
+ ("execution_contract_digest", "data_contract_digest"),
+)
+def test_execution_or_data_contract_drift_is_rejected_before_core_load(
+ tmp_path: Path,
+ identity_field: str,
+) -> None:
+ """Objective and loader digests participate in the manifest preflight gate."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ TrainerRuntimeState(identity=_identity()).prepare_save(checkpoint)
+ trainer = _loading_trainer(checkpoint)
+ target_identity = _identity()
+ target_identity[identity_field] = "f" * 64
+ trainer.runtime_state = TrainerRuntimeState(identity=target_identity)
+
+ with pytest.raises(ValueError, match=f"identity mismatch.*{identity_field}"):
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.load_calls == 0
+ assert not trainer.adapter.core_mutated
+
+
+def test_corrupt_core_artifact_is_rejected_before_adapter_mutation(tmp_path: Path) -> None:
+ """Manifest hashes gate the adapter's model/optimizer load call."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source = TrainerRuntimeState(identity=_identity())
+ source.prepare_save(checkpoint)
+ (checkpoint / "optimizer.bin").write_bytes(b"corrupt")
+ trainer = _loading_trainer(checkpoint)
+
+ with pytest.raises(RuntimeError, match="size mismatch|SHA-256 mismatch"):
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.load_calls == 0
+ assert not trainer.adapter.core_mutated
+ assert trainer.progress == TrainingProgress()
+
+
+def test_unmanifested_backend_artifact_is_rejected_before_adapter_mutation(
+ tmp_path: Path,
+) -> None:
+ """A stale file cannot become an unhashed input to Accelerator.load_state."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source = TrainerRuntimeState(identity=_identity())
+ source.prepare_save(checkpoint)
+ (checkpoint / "optimizer_1.bin").write_bytes(b"stale")
+ trainer = _loading_trainer(checkpoint)
+
+ with pytest.raises(RuntimeError, match="unmanifested=.*optimizer_1.bin"):
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.load_calls == 0
+ assert not trainer.adapter.core_mutated
+
+
+def test_preflight_recognizes_sharded_fsdp_and_nonzero_rank_rng_artifacts(
+ tmp_path: Path,
+) -> None:
+ """Exact resume does not assume plain Accelerator filenames or global rank zero."""
+ checkpoint = tmp_path / "checkpoint"
+ (checkpoint / "pytorch_model_fsdp_0").mkdir(parents=True)
+ (checkpoint / "optimizer_0").mkdir()
+ (checkpoint / "pytorch_model_fsdp_0" / ".metadata").write_bytes(b"model")
+ (checkpoint / "optimizer_0" / ".metadata").write_bytes(b"optimizer")
+ _write_rng_artifact(checkpoint / "random_states_4.pkl")
+ source = TrainerRuntimeState(identity=_identity(world_size=5))
+ source.prepare_save(checkpoint)
+ restored = TrainerRuntimeState(identity=_identity(world_size=5))
+
+ restored.validate_load(
+ checkpoint,
+ expected_process_index=4,
+ expected_device_type="cpu",
+ )
+ restored.commit_validated_load()
+
+ assert restored.load_received
+
+
+def test_preflight_requires_the_current_rank_rng_before_core_mutation(
+ tmp_path: Path,
+) -> None:
+ """Another rank's valid RNG payload cannot make this rank's resume exact."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source = TrainerRuntimeState(identity=_identity(world_size=2))
+ source.prepare_save(checkpoint)
+ restored = TrainerRuntimeState(identity=_identity(world_size=2))
+
+ with pytest.raises(RuntimeError, match="current rank RNG.*random_states_1.pkl"):
+ restored.validate_load(
+ checkpoint,
+ expected_process_index=1,
+ expected_device_type="cpu",
+ )
+
+ assert not restored.validated_load_pending
+
+
+def test_preflight_requires_the_active_device_rng_payload(tmp_path: Path) -> None:
+ """Accelerate's silent CUDA RNG fallback is rejected before state mutation."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source = TrainerRuntimeState(identity=_identity())
+ source.prepare_save(checkpoint)
+ restored = TrainerRuntimeState(identity=_identity())
+
+ with pytest.raises(ValueError, match="missing required keys.*torch_cuda_manual_seed"):
+ restored.validate_load(
+ checkpoint,
+ expected_process_index=0,
+ expected_device_type="cuda",
+ )
+
+ assert not restored.validated_load_pending
+
+
+def test_preflight_rejects_device_rng_topology_drift(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A saved per-device RNG list must match the current visible device count."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ rng_path = checkpoint / "random_states_0.pkl"
+ rng_state = torch.load(rng_path, map_location="cpu", weights_only=False)
+ rng_state["torch_cuda_manual_seed"] = [torch.zeros(16, dtype=torch.uint8)]
+ torch.save(rng_state, rng_path)
+ source = TrainerRuntimeState(identity=_identity())
+ source.prepare_save(checkpoint)
+ restored = TrainerRuntimeState(identity=_identity())
+ monkeypatch.setattr(torch.cuda, "device_count", lambda: 2)
+
+ with pytest.raises(ValueError, match="device-state topology mismatch.*expected 2"):
+ restored.validate_load(
+ checkpoint,
+ expected_process_index=0,
+ expected_device_type="cuda",
+ )
+
+ assert not restored.validated_load_pending
+
+
+def test_backend_identity_drift_is_rejected_before_core_state_validation(
+ tmp_path: Path,
+) -> None:
+ """A checkpoint cannot cross prepared-state backend layouts by accident."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source = TrainerRuntimeState(identity=_identity())
+ source.prepare_save(checkpoint)
+ target_identity = _identity()
+ target_identity["distributed_type"] = "FSDP"
+ target_identity["backend_schema_digest"] = "d" * 64
+ restored = TrainerRuntimeState(identity=target_identity)
+
+ with pytest.raises(ValueError, match="identity mismatch.*distributed_type.*FSDP"):
+ restored.validate_load(
+ checkpoint,
+ expected_process_index=0,
+ expected_device_type="cpu",
+ )
+
+ assert not restored.validated_load_pending
+
+
+def test_preflight_requires_scaler_artifact_before_model_mutation(tmp_path: Path) -> None:
+ """An fp16 runtime cannot discover a missing scaler after loading model state."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ identity = _identity()
+ identity["mixed_precision"] = "fp16"
+ identity["gradient_scaler"] = "torch.amp.grad_scaler.GradScaler"
+ source = TrainerRuntimeState(identity=identity)
+ source.prepare_save(checkpoint)
+ restored = TrainerRuntimeState(identity=identity)
+
+ with pytest.raises(RuntimeError, match="missing the gradient scaler artifact"):
+ restored.validate_load(
+ checkpoint,
+ expected_process_index=0,
+ expected_device_type="cpu",
+ )
+
+ assert not restored.validated_load_pending
+
+
+def test_failed_core_load_does_not_commit_runtime_progress(tmp_path: Path) -> None:
+ """A backend load exception leaves the validated runtime payload uncommitted."""
+ checkpoint = tmp_path / "checkpoint"
+ _write_accelerate_artifacts(checkpoint)
+ source = TrainerRuntimeState(
+ TrainingProgress(optimizer_step=9, data_epoch=3),
+ identity=_identity(),
+ )
+ source.prepare_save(checkpoint)
+ trainer = _loading_trainer(checkpoint, fail_load=True)
+
+ with pytest.raises(RuntimeError, match="accelerator load failed"):
+ _resume_loading_trainer(trainer)
+
+ assert trainer.adapter.load_calls == 1
+ assert trainer.adapter.core_mutated
+ assert trainer.progress == TrainingProgress()
+ assert trainer.runtime_state.validated_load_pending
+ assert not trainer.runtime_state.load_received
+
+
+def test_public_late_state_load_is_rejected_before_adapter_call() -> None:
+ """Exact state restore cannot bypass constructor-time identity preflight."""
+ trainer = object.__new__(BaseTrainer)
+ trainer.adapter = SimpleNamespace(load_checkpoint=lambda **kwargs: pytest.fail(str(kwargs)))
+
+ with pytest.raises(RuntimeError, match="must be configured.*before trainer construction"):
+ trainer.load_checkpoint("checkpoint", resume_type="state")
diff --git a/tests/trainers/test_runtime_identity.py b/tests/trainers/test_runtime_identity.py
new file mode 100644
index 000000000..3f8cbb635
--- /dev/null
+++ b/tests/trainers/test_runtime_identity.py
@@ -0,0 +1,816 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for deterministic realized trainer resume identities."""
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+import torch
+from accelerate.data_loader import BatchSamplerShard, DataLoaderShard
+from accelerate.utils import DistributedType
+from torch.utils.data import ConcatDataset, DataLoader, DistributedSampler
+
+from flow_factory.contracts.execution import OFFLINE_EXECUTION_CONTRACT
+from flow_factory.data_utils.dataset import GeneralDataset
+from flow_factory.data_utils.multi_source import (
+ MultiSourceTrainDataLoader,
+ WeightedSourceBatchScheduler,
+)
+from flow_factory.data_utils.offline_dataset import OfflineDataset
+from flow_factory.data_utils.sampler import DistributedKRepeatSampler
+from flow_factory.hparams.optimizer_args import (
+ AdamWOptimizerArguments,
+ MultiOptimizerArguments,
+)
+from flow_factory.trainers.common.runtime_identity import (
+ build_trainer_runtime_identity,
+)
+
+
+@dataclass
+class _Record:
+ """Expose the stable parameter ownership fields used by the identity builder."""
+
+ component_name: str
+ parameter_name: str
+ parameter: torch.nn.Parameter
+
+
+class _Registry:
+ """Return ordered role-owned parameter records."""
+
+ def __init__(self, records: dict[str, tuple[_Record, ...]]) -> None:
+ self.records = records
+
+ def parameter_records(self, role_name: str) -> tuple[_Record, ...]:
+ """Return records for one role."""
+ return self.records[role_name]
+
+
+class _Adapter:
+ """Carry the realized registry without model-loading behavior."""
+
+
+class _ConfigBlock:
+ """Expose resolved config values through the production ``to_dict`` contract."""
+
+ def __init__(self, **values: Any) -> None:
+ self.__dict__.update(values)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Return a detached resolved-config mapping."""
+ return dict(self.__dict__)
+
+
+class _FingerprintDataset:
+ """Minimal Hugging Face-like dataset carrying a stable content fingerprint."""
+
+ def __init__(self, fingerprint: str, length: int = 8) -> None:
+ self._fingerprint = fingerprint
+ self._length = length
+
+ def __len__(self) -> int:
+ return self._length
+
+ def __getitem__(self, index: int) -> dict[str, int]:
+ return {"index": index}
+
+
+class _SchedulerGroup:
+ """Provide the realized scheduler-group surface hashed by the trainer."""
+
+ names = ("latent",)
+ primary_name = "latent"
+
+ def __getitem__(self, name: str) -> SimpleNamespace:
+ assert name == "latent"
+ return SimpleNamespace(dynamics_type="Flow-ODE")
+
+
+def _online_loader(
+ *,
+ fingerprint: str = "online-content-v1",
+ seed: int = 17,
+ rank: int = 0,
+) -> DataLoader:
+ """Build the same rank-aware loader geometry used by online acquisition."""
+ dataset = GeneralDataset.__new__(GeneralDataset)
+ dataset.processed_dataset = _FingerprintDataset(fingerprint)
+ sampler = DistributedKRepeatSampler(
+ dataset,
+ batch_size=2,
+ group_size=2,
+ unique_sample_num=4,
+ num_replicas=2,
+ rank=rank,
+ seed=seed,
+ )
+ return DataLoader(dataset, batch_sampler=sampler, collate_fn=GeneralDataset.collate_fn)
+
+
+def _multi_source_online_loader(
+ *,
+ source_order: tuple[str, ...] = ("train-a", "train-b"),
+ source_name_to_id: dict[str, int] | None = None,
+) -> MultiSourceTrainDataLoader:
+ """Build an identity-only multi-source loader with ordered training names."""
+ loaders = {
+ source_name: _online_loader(fingerprint=f"content:{source_name}")
+ for source_name in source_order
+ }
+ scheduler = WeightedSourceBatchScheduler(
+ {
+ source_name: loader.batch_sampler.num_batches_per_epoch
+ for source_name, loader in loaders.items()
+ },
+ seed=42,
+ )
+ return MultiSourceTrainDataLoader(
+ loaders,
+ scheduler,
+ source_name_to_id=source_name_to_id,
+ batch_size=2,
+ )
+
+
+def _prepared_eval_loader(
+ *,
+ fingerprint: str = "eval-content-v1",
+ rank: int = 0,
+) -> DataLoaderShard:
+ """Build the Accelerate wrapper shape used by realized eval loaders."""
+ dataset = _FingerprintDataset(fingerprint, length=8)
+ source = DataLoader(dataset, batch_size=2, shuffle=False)
+ batch_sampler = BatchSamplerShard(
+ source.batch_sampler,
+ num_processes=2,
+ process_index=rank,
+ )
+ return DataLoaderShard(
+ dataset,
+ batch_sampler=batch_sampler,
+ collate_fn=source.collate_fn,
+ )
+
+
+def _configure_evaluation(
+ trainer: "_Trainer",
+ loaders: tuple[tuple[str, DataLoader], ...],
+) -> None:
+ """Attach ordered realized eval loaders and matching dataset configs."""
+ trainer.eval_dataloaders = dict(loaders)
+ trainer._eval_dataset_configs = {
+ name: _ConfigBlock(
+ name=name,
+ source_id=index,
+ eval={"enabled": True, "guidance_scale": None},
+ )
+ for index, (name, _) in enumerate(loaders)
+ }
+
+
+class _Trainer:
+ """Provide the identity builder's structural trainer interface."""
+
+ execution_contract = OFFLINE_EXECUTION_CONTRACT
+
+ def __init__(
+ self,
+ *,
+ width: int = 2,
+ learning_rate: float = 1e-3,
+ beta: float = 0.1,
+ weighting_scheme: str = "uniform",
+ timestep_range: tuple[float, float] = (0.0, 1.0),
+ seed: int = 42,
+ max_epochs: int = 2,
+ log_every: int = 10,
+ max_grad_norm: float = 1.0,
+ update_frequency: int = 1,
+ dataloader: DataLoader | None = None,
+ ) -> None:
+ parameter = torch.nn.Parameter(torch.zeros(width, width))
+ self.adapter = _Adapter()
+ self.adapter.component_variant_registry = _Registry(
+ {"base": (_Record("transformer", "weight", parameter),)}
+ )
+ self.adapter.scheduler_group = _SchedulerGroup()
+ self.optimizer = torch.optim.AdamW(
+ [
+ {
+ "params": [parameter],
+ "role_name": "base",
+ "lr": learning_rate,
+ }
+ ]
+ )
+ self.model_args = _ConfigBlock(
+ model_type="tiny",
+ model_name_or_path="tests/tiny",
+ finetune_type="full",
+ target_components=["transformer"],
+ forward_variant="epsilon",
+ resume_path="checkpoint-source",
+ resume_type="state",
+ )
+ self.training_args = _ConfigBlock(
+ trainer_type="sft",
+ beta=beta,
+ weighting_scheme=weighting_scheme,
+ timestep_range=timestep_range,
+ seed=seed,
+ max_epochs=max_epochs,
+ per_device_batch_size=2,
+ gradient_accumulation_steps=1,
+ )
+ self.config = SimpleNamespace(
+ scheduler_args=_ConfigBlock(
+ dynamics_type="Flow-ODE",
+ seed=9,
+ num_sde_steps=0,
+ ),
+ acceleration_args=_ConfigBlock(shared=[], rollout=[]),
+ reward_args=_ConfigBlock(
+ rewards=[{"name": "quality", "reward_model": "tests.Reward", "weight": 1.0}]
+ ),
+ eval_args=_ConfigBlock(eval_freq=1, guidance_scale=4.0),
+ log_args=_ConfigBlock(log_every=log_every, save_freq=1),
+ optimizer_args=MultiOptimizerArguments(
+ optimizer_configs=[
+ AdamWOptimizerArguments(
+ name="base",
+ learning_rate=learning_rate,
+ weight_decay=1e-2,
+ max_grad_norm=max_grad_norm,
+ update_frequency=update_frequency,
+ )
+ ]
+ ),
+ )
+ self.reward_args = self.config.reward_args
+ self.eval_args = self.config.eval_args
+ self.eval_reward_args = self.reward_args
+ self.eval_dataloaders: dict[str, DataLoader] = {}
+ self._eval_dataset_configs: dict[str, _ConfigBlock] = {}
+ self.dataloader = _online_loader() if dataloader is None else dataloader
+ self.accelerator = SimpleNamespace(
+ num_processes=2,
+ distributed_type=DistributedType.NO,
+ mixed_precision="no",
+ scaler=None,
+ state=SimpleNamespace(fsdp_plugin=None, deepspeed_plugin=None),
+ )
+
+ def _required_trainable_roles(self) -> tuple[str, ...]:
+ """Return the realized optimizer-role order."""
+ return ("base",)
+
+ def _optimizer_args_for_role(self, role_name: str) -> AdamWOptimizerArguments:
+ """Return the realized optimizer arguments for one role."""
+ optimizer_args = self.config.optimizer_args.get_by_name(role_name)
+ assert optimizer_args is not None
+ return optimizer_args
+
+
+def test_identity_covers_concrete_types_model_roles_and_world_size() -> None:
+ """Human-readable fields and opaque schemas cover separate compatibility axes."""
+ trainer = _Trainer()
+
+ identity = build_trainer_runtime_identity(trainer)
+
+ assert identity["trainer"].endswith("._Trainer")
+ assert identity["adapter"].endswith("._Adapter")
+ assert identity["algorithm"] == "sft"
+ assert identity["model"] == "tiny:tests/tiny"
+ assert identity["finetune_type"] == "full"
+ assert identity["optimizer_roles"] == ("base",)
+ assert identity["world_size"] == 2
+ assert identity["distributed_type"] == "NO"
+ assert identity["mixed_precision"] == "no"
+ assert identity["gradient_scaler"] == "none"
+ assert len(identity["backend_schema_digest"]) == 64
+ assert len(identity["parameter_schema_digest"]) == 64
+ assert len(identity["optimizer_schema_digest"]) == 64
+ assert len(identity["execution_contract_digest"]) == 64
+ assert len(identity["data_contract_digest"]) == 64
+
+
+def test_parameter_and_optimizer_schema_changes_have_independent_digests() -> None:
+ """Shape and optimizer configuration drift are both exact-resume incompatibilities."""
+ baseline = build_trainer_runtime_identity(_Trainer(width=2, learning_rate=1e-3))
+ changed_shape = build_trainer_runtime_identity(_Trainer(width=3, learning_rate=1e-3))
+ changed_optimizer = build_trainer_runtime_identity(_Trainer(width=2, learning_rate=2e-3))
+
+ assert changed_shape["parameter_schema_digest"] != baseline["parameter_schema_digest"]
+ assert changed_optimizer["parameter_schema_digest"] == baseline["parameter_schema_digest"]
+ assert changed_optimizer["optimizer_schema_digest"] != baseline["optimizer_schema_digest"]
+
+
+@pytest.mark.parametrize(
+ ("field", "changed_value"),
+ (("max_grad_norm", 0.5), ("update_frequency", 3)),
+)
+def test_non_param_group_optimizer_semantics_change_execution_contract_digest(
+ field: str,
+ changed_value: Any,
+) -> None:
+ """Gradient clipping and role cadence cannot drift across an exact resume."""
+ baseline = build_trainer_runtime_identity(_Trainer())
+ changed = build_trainer_runtime_identity(_Trainer(**{field: changed_value}))
+
+ assert changed["optimizer_schema_digest"] == baseline["optimizer_schema_digest"]
+ assert changed["execution_contract_digest"] != baseline["execution_contract_digest"]
+
+
+def test_optimizer_schema_rejects_parameters_outside_rebound_registry() -> None:
+ """A prepared optimizer cannot restore state onto an unowned physical parameter."""
+ trainer = _Trainer()
+ trainer.optimizer.param_groups[0]["params"].append(torch.nn.Parameter(torch.zeros(1)))
+
+ with pytest.raises(ValueError, match="not owned by the rebound variant registry"):
+ build_trainer_runtime_identity(trainer)
+
+
+def test_backend_and_precision_drift_change_the_resume_identity() -> None:
+ """Backend checkpoint layouts are rejected before prepared-state mutation."""
+ baseline_trainer = _Trainer()
+ baseline = build_trainer_runtime_identity(baseline_trainer)
+
+ fsdp_trainer = _Trainer()
+ fsdp_trainer.accelerator.distributed_type = DistributedType.FSDP
+ fsdp_trainer.accelerator.state.fsdp_plugin = SimpleNamespace(
+ fsdp_version=2,
+ state_dict_type="SHARDED_STATE_DICT",
+ state_dict_config={"offload_to_cpu": True, "rank0_only": False},
+ optim_state_dict_config={"offload_to_cpu": True, "rank0_only": False},
+ sharding_strategy="FULL_SHARD",
+ reshard_after_forward=True,
+ use_orig_params=True,
+ cpu_offload=False,
+ mixed_precision_policy=None,
+ backward_prefetch=None,
+ forward_prefetch=False,
+ auto_wrap_policy=None,
+ transformer_cls_names_to_wrap=None,
+ min_num_params=None,
+ limit_all_gathers=True,
+ sync_module_states=True,
+ cpu_ram_efficient_loading=True,
+ activation_checkpointing=False,
+ )
+ fsdp = build_trainer_runtime_identity(fsdp_trainer)
+
+ fp16_trainer = _Trainer()
+ fp16_trainer.accelerator.mixed_precision = "fp16"
+ fp16_trainer.accelerator.scaler = SimpleNamespace()
+ fp16 = build_trainer_runtime_identity(fp16_trainer)
+
+ assert fsdp["distributed_type"] == "FSDP"
+ assert fsdp["backend_schema_digest"] != baseline["backend_schema_digest"]
+ assert fp16["mixed_precision"] == "fp16"
+ assert fp16["gradient_scaler"].endswith("SimpleNamespace")
+
+
+def test_fsdp_wrap_and_checkpoint_topology_drift_changes_backend_digest() -> None:
+ """FSDP wrap units and state-dict policy are exact-resume identity fields."""
+
+ def identity(*, min_num_params: int, rank0_only: bool) -> dict[str, Any]:
+ trainer = _Trainer()
+ trainer.accelerator.distributed_type = DistributedType.FSDP
+ trainer.accelerator.state.fsdp_plugin = SimpleNamespace(
+ fsdp_version=1,
+ state_dict_type="FULL_STATE_DICT",
+ state_dict_config={"offload_to_cpu": True, "rank0_only": rank0_only},
+ optim_state_dict_config={"offload_to_cpu": True, "rank0_only": rank0_only},
+ sharding_strategy="FULL_SHARD",
+ reshard_after_forward=None,
+ use_orig_params=False,
+ cpu_offload=False,
+ mixed_precision_policy=None,
+ backward_prefetch=None,
+ forward_prefetch=False,
+ auto_wrap_policy="size_based_auto_wrap_policy",
+ transformer_cls_names_to_wrap=None,
+ min_num_params=min_num_params,
+ limit_all_gathers=True,
+ sync_module_states=True,
+ cpu_ram_efficient_loading=False,
+ activation_checkpointing=False,
+ )
+ return build_trainer_runtime_identity(trainer)
+
+ baseline = identity(min_num_params=1_000, rank0_only=True)
+ changed_wrap = identity(min_num_params=2_000, rank0_only=True)
+ changed_state_dict = identity(min_num_params=1_000, rank0_only=False)
+
+ assert changed_wrap["backend_schema_digest"] != baseline["backend_schema_digest"]
+ assert changed_state_dict["backend_schema_digest"] != baseline["backend_schema_digest"]
+
+
+def test_deepspeed_batch_and_accumulation_drift_changes_backend_digest() -> None:
+ """DeepSpeed engine cadence and batch geometry participate in exact resume."""
+
+ def identity(*, micro_batch: int, accumulation_steps: int) -> dict[str, Any]:
+ trainer = _Trainer()
+ trainer.accelerator.distributed_type = DistributedType.DEEPSPEED
+ trainer.accelerator.gradient_accumulation_steps = accumulation_steps
+ config = {
+ "train_micro_batch_size_per_gpu": micro_batch,
+ "gradient_accumulation_steps": accumulation_steps,
+ "zero_optimization": {"stage": 2},
+ }
+ trainer.accelerator.state.deepspeed_plugin = SimpleNamespace(
+ zero_stage=2,
+ deepspeed_config=config,
+ gradient_accumulation_steps=accumulation_steps,
+ gradient_clipping="auto",
+ is_train_batch_min=True,
+ )
+ return build_trainer_runtime_identity(trainer)
+
+ baseline = identity(micro_batch=1, accumulation_steps=2)
+ changed_micro_batch = identity(micro_batch=2, accumulation_steps=2)
+ changed_accumulation = identity(micro_batch=1, accumulation_steps=4)
+
+ assert changed_micro_batch["backend_schema_digest"] != baseline["backend_schema_digest"]
+ assert changed_accumulation["backend_schema_digest"] != baseline["backend_schema_digest"]
+
+
+@pytest.mark.parametrize(
+ ("field", "changed_value"),
+ (
+ ("beta", 0.2),
+ ("weighting_scheme", "logit_normal"),
+ ("timestep_range", (0.2, 0.8)),
+ ("seed", 123),
+ ),
+)
+def test_objective_sampling_and_seed_drift_change_execution_contract_digest(
+ field: str,
+ changed_value: Any,
+) -> None:
+ """Resolved objective, time sampling, and RNG cadence gate exact resume."""
+ baseline = build_trainer_runtime_identity(_Trainer())
+ changed = build_trainer_runtime_identity(_Trainer(**{field: changed_value}))
+
+ assert changed["execution_contract_digest"] != baseline["execution_contract_digest"]
+
+
+def test_scheduler_reward_acceleration_and_model_forward_drift_change_execution_digest() -> None:
+ """Every model-forward input outside the optimizer schema is still identity-locked."""
+ baseline_trainer = _Trainer()
+ baseline = build_trainer_runtime_identity(baseline_trainer)
+
+ changed_scheduler = _Trainer()
+ changed_scheduler.config.scheduler_args.seed = 10
+ changed_reward = _Trainer()
+ changed_reward.reward_args.rewards[0]["weight"] = 2.0
+ changed_acceleration = _Trainer()
+ changed_acceleration.config.acceleration_args.shared = [
+ {"name": "attention_backend", "params": {"backend": "sdpa"}}
+ ]
+ changed_forward = _Trainer()
+ changed_forward.model_args.forward_variant = "velocity"
+
+ for changed_trainer in (
+ changed_scheduler,
+ changed_reward,
+ changed_acceleration,
+ changed_forward,
+ ):
+ changed = build_trainer_runtime_identity(changed_trainer)
+ assert changed["execution_contract_digest"] != baseline["execution_contract_digest"]
+
+
+def test_budget_logging_checkpoint_cadence_and_resume_location_are_operational() -> None:
+ """Non-computational controls may change without pretending the math changed."""
+ baseline_trainer = _Trainer(max_epochs=2, log_every=10)
+ changed_trainer = _Trainer(max_epochs=20, log_every=1)
+ changed_trainer.config.log_args.save_freq = 5
+ changed_trainer.model_args.resume_path = "different-checkpoint"
+ changed_trainer.model_args.resume_type = "full"
+
+ baseline = build_trainer_runtime_identity(baseline_trainer)
+ changed = build_trainer_runtime_identity(changed_trainer)
+
+ assert changed["execution_contract_digest"] == baseline["execution_contract_digest"]
+
+
+@pytest.mark.parametrize(
+ ("field", "changed_value"),
+ (("eval_freq", 4), ("guidance_scale", 7.0)),
+)
+def test_evaluation_cadence_and_sampling_change_execution_contract_digest(
+ field: str,
+ changed_value: Any,
+) -> None:
+ """Online exact resume replays eval, so its RNG-consuming semantics are locked."""
+ baseline = build_trainer_runtime_identity(_Trainer())
+ changed_trainer = _Trainer()
+ setattr(changed_trainer.eval_args, field, changed_value)
+ changed = build_trainer_runtime_identity(changed_trainer)
+
+ assert changed["execution_contract_digest"] != baseline["execution_contract_digest"]
+
+
+def test_evaluation_reward_semantics_change_execution_contract_digest() -> None:
+ """A replayed eval reward implementation is part of exact continuation."""
+ baseline = build_trainer_runtime_identity(_Trainer())
+ changed_trainer = _Trainer()
+ changed_trainer.eval_reward_args = _ConfigBlock(
+ rewards=[{"name": "aesthetic", "reward_model": "tests.OtherReward", "weight": 1.0}]
+ )
+ changed = build_trainer_runtime_identity(changed_trainer)
+
+ assert changed["execution_contract_digest"] != baseline["execution_contract_digest"]
+
+
+def test_evaluation_dataset_overrides_change_execution_contract_digest() -> None:
+ """Per-dataset eval overrides control replayed adapter sampling."""
+ baseline_trainer = _Trainer()
+ changed_trainer = _Trainer()
+ for trainer in (baseline_trainer, changed_trainer):
+ _configure_evaluation(trainer, (("eval-a", _prepared_eval_loader()),))
+ changed_trainer._eval_dataset_configs["eval-a"].eval["guidance_scale"] = 6.0
+
+ baseline = build_trainer_runtime_identity(baseline_trainer)
+ changed = build_trainer_runtime_identity(changed_trainer)
+
+ assert changed["execution_contract_digest"] != baseline["execution_contract_digest"]
+ assert changed["data_contract_digest"] == baseline["data_contract_digest"]
+
+
+def test_evaluation_loader_content_and_order_change_data_contract_digest() -> None:
+ """Replayed eval inputs and their RNG-consuming traversal order stay locked."""
+ baseline_trainer = _Trainer()
+ _configure_evaluation(
+ baseline_trainer,
+ (
+ ("eval-a", _prepared_eval_loader(fingerprint="eval-a")),
+ ("eval-b", _prepared_eval_loader(fingerprint="eval-b")),
+ ),
+ )
+ changed_content_trainer = _Trainer()
+ _configure_evaluation(
+ changed_content_trainer,
+ (
+ ("eval-a", _prepared_eval_loader(fingerprint="eval-a-v2")),
+ ("eval-b", _prepared_eval_loader(fingerprint="eval-b")),
+ ),
+ )
+ reordered_trainer = _Trainer()
+ _configure_evaluation(
+ reordered_trainer,
+ (
+ ("eval-b", _prepared_eval_loader(fingerprint="eval-b")),
+ ("eval-a", _prepared_eval_loader(fingerprint="eval-a")),
+ ),
+ )
+
+ baseline = build_trainer_runtime_identity(baseline_trainer)
+ changed_content = build_trainer_runtime_identity(changed_content_trainer)
+ reordered = build_trainer_runtime_identity(reordered_trainer)
+
+ assert changed_content["data_contract_digest"] != baseline["data_contract_digest"]
+ assert reordered["data_contract_digest"] != baseline["data_contract_digest"]
+
+
+def test_prepared_evaluation_loader_identity_excludes_process_index() -> None:
+ """All ranks agree on one eval data contract after Accelerate sharding."""
+ rank_zero_trainer = _Trainer()
+ rank_one_trainer = _Trainer()
+ _configure_evaluation(
+ rank_zero_trainer,
+ (("eval-a", _prepared_eval_loader(rank=0)),),
+ )
+ _configure_evaluation(
+ rank_one_trainer,
+ (("eval-a", _prepared_eval_loader(rank=1)),),
+ )
+
+ rank_zero = build_trainer_runtime_identity(rank_zero_trainer)
+ rank_one = build_trainer_runtime_identity(rank_one_trainer)
+
+ assert rank_one["data_contract_digest"] == rank_zero["data_contract_digest"]
+
+
+def _offline_dataset(
+ *,
+ record_ids: tuple[str, ...] = ("record-a", "record-b"),
+ condition_ids: tuple[str, ...] = ("condition-a", "condition-b"),
+ source_name: str = "source",
+ source_id: int = 0,
+) -> OfflineDataset:
+ """Create an identity-only offline dataset without decoding target media."""
+ dataset = OfflineDataset.__new__(OfflineDataset)
+ dataset._records = tuple(None for _ in record_ids)
+ dataset._record_ids = record_ids
+ dataset._condition_ids = condition_ids
+ dataset._condition_cache = _FingerprintDataset("condition-cache-v1", len(record_ids))
+ dataset.source_name = source_name
+ dataset.source_id = source_id
+ dataset.supervision_type = "demonstration"
+ return dataset
+
+
+def _offline_loader(
+ *,
+ dataset: OfflineDataset | None = None,
+ seed: int = 42,
+ rank: int = 0,
+ shuffle: bool = True,
+ drop_last: bool = False,
+ batch_size: int = 1,
+) -> DataLoader:
+ """Build an official distributed offline loader for identity tests."""
+ concatenated = ConcatDataset([_offline_dataset() if dataset is None else dataset])
+ sampler = DistributedSampler(
+ concatenated,
+ num_replicas=2,
+ rank=rank,
+ shuffle=shuffle,
+ seed=seed,
+ drop_last=drop_last,
+ )
+ return DataLoader(
+ concatenated,
+ batch_size=batch_size,
+ sampler=sampler,
+ drop_last=drop_last,
+ )
+
+
+@pytest.mark.parametrize(
+ "loader",
+ (
+ _offline_loader(dataset=_offline_dataset(record_ids=("record-a", "target-changed"))),
+ _offline_loader(
+ dataset=_offline_dataset(condition_ids=("condition-a", "manifest-changed"))
+ ),
+ _offline_loader(dataset=_offline_dataset(source_name="renamed-source")),
+ _offline_loader(seed=99),
+ _offline_loader(shuffle=False),
+ _offline_loader(drop_last=True),
+ _offline_loader(batch_size=2),
+ ),
+)
+def test_offline_records_source_and_sampler_semantics_change_data_contract_digest(
+ loader: DataLoader,
+) -> None:
+ """Ordered targets, conditions, provenance, and sampler policy are exact inputs."""
+ baseline = build_trainer_runtime_identity(_Trainer(dataloader=_offline_loader()))
+ changed = build_trainer_runtime_identity(_Trainer(dataloader=loader))
+
+ assert changed["data_contract_digest"] != baseline["data_contract_digest"]
+
+
+def test_data_contract_tracks_source_order_and_gradient_accumulation() -> None:
+ """Cross-source traversal order and complete optimizer windows are locked."""
+ source_a = _offline_dataset(
+ record_ids=("record-a1", "record-a2"),
+ condition_ids=("condition-a1", "condition-a2"),
+ source_name="a",
+ source_id=0,
+ )
+ source_b = _offline_dataset(
+ record_ids=("record-b1", "record-b2"),
+ condition_ids=("condition-b1", "condition-b2"),
+ source_name="b",
+ source_id=1,
+ )
+
+ def loader(sources: tuple[OfflineDataset, ...]) -> DataLoader:
+ dataset = ConcatDataset(sources)
+ sampler = DistributedSampler(
+ dataset,
+ num_replicas=2,
+ rank=0,
+ shuffle=True,
+ seed=42,
+ )
+ return DataLoader(dataset, batch_size=1, sampler=sampler)
+
+ baseline_trainer = _Trainer(dataloader=loader((source_a, source_b)))
+ reordered_trainer = _Trainer(dataloader=loader((source_b, source_a)))
+ changed_accumulation = _Trainer(dataloader=loader((source_a, source_b)))
+ changed_accumulation.training_args.gradient_accumulation_steps = 2
+
+ baseline = build_trainer_runtime_identity(baseline_trainer)
+ reordered = build_trainer_runtime_identity(reordered_trainer)
+ changed_gas = build_trainer_runtime_identity(changed_accumulation)
+
+ assert reordered["data_contract_digest"] != baseline["data_contract_digest"]
+ assert changed_gas["data_contract_digest"] != baseline["data_contract_digest"]
+
+
+def test_offline_data_identity_excludes_global_transport_source_ids() -> None:
+ """Eval-only entries may renumber offline sources without changing training."""
+
+ def identity(source_ids: tuple[int, int]) -> dict[str, Any]:
+ sources = (
+ _offline_dataset(
+ record_ids=("record-a1", "record-a2"),
+ condition_ids=("condition-a1", "condition-a2"),
+ source_name="train-a",
+ source_id=source_ids[0],
+ ),
+ _offline_dataset(
+ record_ids=("record-b1", "record-b2"),
+ condition_ids=("condition-b1", "condition-b2"),
+ source_name="train-b",
+ source_id=source_ids[1],
+ ),
+ )
+ dataset = ConcatDataset(sources)
+ sampler = DistributedSampler(
+ dataset,
+ num_replicas=2,
+ rank=0,
+ shuffle=True,
+ seed=42,
+ )
+ return build_trainer_runtime_identity(
+ _Trainer(dataloader=DataLoader(dataset, batch_size=1, sampler=sampler))
+ )
+
+ baseline = identity((0, 2))
+ eval_sources_inserted_and_reordered = identity((3, 1))
+
+ assert (
+ eval_sources_inserted_and_reordered["data_contract_digest"]
+ == baseline["data_contract_digest"]
+ )
+
+
+def test_loader_identity_excludes_rank_but_tracks_online_content_and_sampler_seed() -> None:
+ """Every rank hashes one contract while content and order changes remain visible."""
+ rank_zero = build_trainer_runtime_identity(_Trainer(dataloader=_online_loader(rank=0)))
+ rank_one = build_trainer_runtime_identity(_Trainer(dataloader=_online_loader(rank=1)))
+ changed_content = build_trainer_runtime_identity(
+ _Trainer(dataloader=_online_loader(fingerprint="online-content-v2"))
+ )
+ changed_seed = build_trainer_runtime_identity(_Trainer(dataloader=_online_loader(seed=18)))
+
+ assert rank_one["data_contract_digest"] == rank_zero["data_contract_digest"]
+ assert changed_content["data_contract_digest"] != rank_zero["data_contract_digest"]
+ assert changed_seed["data_contract_digest"] != rank_zero["data_contract_digest"]
+
+
+def test_eval_only_source_registry_changes_do_not_renumber_multi_source_identity() -> None:
+ """Full global source-ID maps are transport metadata, not training semantics."""
+ baseline = build_trainer_runtime_identity(
+ _Trainer(
+ dataloader=_multi_source_online_loader(
+ source_name_to_id={"train-a": 0, "eval-only": 1, "train-b": 2}
+ )
+ )
+ )
+ eval_sources_inserted_and_reordered = build_trainer_runtime_identity(
+ _Trainer(
+ dataloader=_multi_source_online_loader(
+ source_name_to_id={
+ "eval-second": 0,
+ "train-a": 1,
+ "eval-only": 2,
+ "train-b": 3,
+ }
+ )
+ )
+ )
+ reversed_training_order = build_trainer_runtime_identity(
+ _Trainer(
+ dataloader=_multi_source_online_loader(
+ source_order=("train-b", "train-a"),
+ source_name_to_id={"train-b": 0, "train-a": 1},
+ )
+ )
+ )
+ one_training_source_removed = build_trainer_runtime_identity(
+ _Trainer(
+ dataloader=_multi_source_online_loader(
+ source_order=("train-a",),
+ source_name_to_id={"train-a": 0},
+ )
+ )
+ )
+
+ assert (
+ eval_sources_inserted_and_reordered["data_contract_digest"]
+ == baseline["data_contract_digest"]
+ )
+ assert reversed_training_order["data_contract_digest"] != baseline["data_contract_digest"]
+ assert one_training_source_removed["data_contract_digest"] != baseline["data_contract_digest"]
diff --git a/tests/trainers/test_runtime_snapshot_lifecycle.py b/tests/trainers/test_runtime_snapshot_lifecycle.py
new file mode 100644
index 000000000..c11e8d184
--- /dev/null
+++ b/tests/trainers/test_runtime_snapshot_lifecycle.py
@@ -0,0 +1,255 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Regression coverage for algorithm-owned exact-resume snapshots."""
+
+from types import SimpleNamespace
+from typing import Any, List
+
+import torch
+from accelerate.utils import DistributedType
+
+from flow_factory.models.abc import BaseAdapter
+from flow_factory.trainers.common.runtime_state import TrainerRuntimeState
+from flow_factory.trainers.distillation.opd.trainer import DiffusionOPDTrainer
+from flow_factory.trainers.rl.crd import CRDTrainer
+from flow_factory.trainers.rl.dgpo import DGPOTrainer
+
+
+class _SnapshotModule(torch.nn.Module):
+ """Expose one trainable parameter to the real snapshot implementation."""
+
+ def __init__(self, value: float = 1.0) -> None:
+ super().__init__()
+ self.weight = torch.nn.Parameter(torch.tensor(value))
+
+
+class _SnapshotAdapter(BaseAdapter):
+ """Provide the minimal component runtime needed by named snapshots."""
+
+ def load_pipeline(self) -> Any:
+ """Satisfy the adapter abstract contract."""
+ raise NotImplementedError
+
+ def decode_latents(self, *args: Any, **kwargs: Any) -> Any:
+ """Satisfy the adapter abstract contract."""
+ raise NotImplementedError
+
+ def inference(self, *args: Any, **kwargs: Any) -> List[Any]:
+ """Satisfy the adapter abstract contract."""
+ raise NotImplementedError
+
+ def forward(self, *args: Any, **kwargs: Any) -> Any:
+ """Satisfy the adapter abstract contract."""
+ raise NotImplementedError
+
+ def has_component(self, name: str) -> bool:
+ """Declare the one snapshot-backed component."""
+ return name == "transformer"
+
+ def get_component(self, name: str) -> torch.nn.Module:
+ """Return the one snapshot-backed component."""
+ if name != "transformer":
+ raise KeyError(f"expected component 'transformer', received {name!r}")
+ return self.snapshot_module
+
+
+def _adapter(value: float = 1.0) -> _SnapshotAdapter:
+ """Build a lightweight adapter using the production snapshot methods."""
+ adapter = object.__new__(_SnapshotAdapter)
+ adapter.snapshot_module = _SnapshotModule(value)
+ adapter.target_module_map = {"transformer": ["weight"]}
+ adapter._named_parameters = {}
+ return adapter
+
+
+def _snapshot_host(
+ trainer_type: type,
+ training_args: Any,
+ *,
+ model_args: Any | None = None,
+) -> Any:
+ """Build a trainer host immediately before its snapshot initialization hook."""
+ trainer = object.__new__(trainer_type)
+ trainer.training_args = training_args
+ trainer.model_args = model_args or SimpleNamespace(
+ finetune_type="lora",
+ resume_path=None,
+ resume_type=None,
+ )
+ trainer.accelerator = SimpleNamespace(device=torch.device("cpu"))
+ trainer.adapter = _adapter()
+ trainer._runtime_children_attached = False
+ trainer.runtime_state = TrainerRuntimeState(
+ child_names=trainer._algorithm_runtime_child_names()
+ )
+ return trainer
+
+
+def _attach_snapshot_children(trainer: Any) -> None:
+ """Attach all initialized children as BaseTrainer does after safe resume."""
+ trainer._attach_runtime_children(trainer._runtime_checkpoint_children())
+
+
+def test_crd_named_snapshots_round_trip_through_runtime_state() -> None:
+ """CRD old and rollout copies remain exact across runtime child restore."""
+ args = SimpleNamespace(ref_param_device="cpu")
+ source = _snapshot_host(CRDTrainer, args)
+ source._initialize_snapshots()
+ with torch.no_grad():
+ source.adapter.get_named_parameters(CRDTrainer._OLD_PARAMS_NAME)[0].fill_(2.0)
+ source.adapter.get_named_parameters(CRDTrainer._SAMPLING_PARAMS_NAME)[0].fill_(3.0)
+ _attach_snapshot_children(source)
+ payload = source.runtime_state.state_dict()
+
+ target = _snapshot_host(CRDTrainer, args)
+ target._initialize_snapshots()
+ target.runtime_state.load_state_dict(payload)
+ _attach_snapshot_children(target)
+
+ assert target.runtime_state.child_names == CRDTrainer.runtime_child_names
+ torch.testing.assert_close(
+ target.adapter.get_named_parameters(CRDTrainer._OLD_PARAMS_NAME)[0],
+ torch.tensor(2.0),
+ rtol=0,
+ atol=0,
+ )
+ torch.testing.assert_close(
+ target.adapter.get_named_parameters(CRDTrainer._SAMPLING_PARAMS_NAME)[0],
+ torch.tensor(3.0),
+ rtol=0,
+ atol=0,
+ )
+
+
+def test_weight_resume_runs_before_crd_snapshot_initialization() -> None:
+ """Model-only resume seeds CRD snapshots from the loaded policy, not init weights."""
+ args = SimpleNamespace(ref_param_device="cpu")
+ model_args = SimpleNamespace(
+ finetune_type="lora",
+ resume_path="checkpoint",
+ resume_type="lora",
+ )
+ trainer = _snapshot_host(CRDTrainer, args, model_args=model_args)
+
+ def load_resumed_policy() -> None:
+ with torch.no_grad():
+ trainer.adapter.snapshot_module.weight.fill_(7.0)
+
+ trainer.adapter.post_init = load_resumed_policy
+ trainer._initialize_adapter_runtime()
+ trainer._initialize_snapshots()
+
+ for name in CRDTrainer.runtime_child_names:
+ torch.testing.assert_close(
+ trainer.adapter.get_named_parameters(name)[0],
+ torch.tensor(7.0),
+ rtol=0,
+ atol=0,
+ )
+
+
+def test_dgpo_declares_only_the_configuration_active_ema_reference() -> None:
+ """Disabled DGPO clipping pays no snapshot cost; enabled clipping is tracked."""
+ disabled_args = SimpleNamespace(
+ clip_dsm=False,
+ clip_kl=False,
+ use_ema_ref=False,
+ ema_ref_device="cpu",
+ ema_ref_max_decay=0.99,
+ ema_ref_ramp_rate=0.01,
+ )
+ disabled = _snapshot_host(DGPOTrainer, disabled_args)
+ disabled._initialize_snapshots()
+ assert disabled.runtime_state.child_names == ()
+ assert disabled.adapter.list_named_parameters() == []
+
+ enabled_args = SimpleNamespace(**vars(disabled_args))
+ enabled_args.clip_dsm = True
+ enabled = _snapshot_host(DGPOTrainer, enabled_args)
+ enabled._initialize_snapshots()
+
+ assert enabled.runtime_state.child_names == ("ema_ref",)
+ assert enabled.adapter.list_named_parameters() == ["ema_ref"]
+ assert (
+ enabled._runtime_checkpoint_children()["ema_ref"]
+ is enabled.adapter._named_parameters["ema_ref"].ema_wrapper
+ )
+ enabled._validate_runtime_child_coverage()
+
+ enabled.accelerator.distributed_type = DistributedType.DEEPSPEED
+ enabled._validate_distributed_runtime_children()
+ enabled.accelerator.distributed_type = DistributedType.FSDP
+ try:
+ enabled._validate_distributed_runtime_children()
+ except RuntimeError as error:
+ assert "FSDP" in str(error) and "ema_ref" in str(error)
+ else:
+ raise AssertionError("FSDP named snapshots must fail before core state mutation")
+
+
+def test_opd_state_resume_predeclares_teachers_without_loading_external_weights() -> None:
+ """Teacher child schemas exist before preflight without mutating the student."""
+ training_args = SimpleNamespace(
+ teachers=[
+ SimpleNamespace(name="teacher_a", path="unavailable-a"),
+ SimpleNamespace(name="teacher_b", path="unavailable-b"),
+ ],
+ teacher_param_device="cpu",
+ )
+ model_args = SimpleNamespace(
+ finetune_type="lora",
+ resume_path="checkpoint",
+ resume_type="state",
+ )
+ trainer = _snapshot_host(
+ DiffusionOPDTrainer,
+ training_args,
+ model_args=model_args,
+ )
+ student_before = trainer.adapter.snapshot_module.weight.detach().clone()
+
+ trainer._initialize_snapshots()
+
+ assert trainer.runtime_state.child_names == ("teacher_a", "teacher_b")
+ assert trainer._teacher_names == ["teacher_a", "teacher_b"]
+ assert trainer.adapter.list_named_parameters() == ["teacher_a", "teacher_b"]
+ torch.testing.assert_close(
+ trainer.adapter.snapshot_module.weight,
+ student_before,
+ rtol=0,
+ atol=0,
+ )
+ trainer._validate_runtime_child_coverage()
+
+
+def test_algorithm_snapshot_names_cannot_shadow_framework_children() -> None:
+ """A configured OPD teacher cannot alias EMA/reference/multi-role state."""
+ training_args = SimpleNamespace(
+ teachers=[SimpleNamespace(name="multirole", path="teacher")],
+ teacher_param_device="cpu",
+ ema_decay=0.0,
+ requires_ref_model=False,
+ )
+ trainer = object.__new__(DiffusionOPDTrainer)
+ trainer.training_args = training_args
+ trainer.model_args = SimpleNamespace(finetune_type="lora")
+ trainer._required_trainable_roles = lambda: ("base",)
+
+ try:
+ trainer._declared_runtime_child_names()
+ except ValueError as error:
+ assert "framework-reserved" in str(error) and "multirole" in str(error)
+ else:
+ raise AssertionError("algorithm snapshots must not shadow framework children")
From 3fbee2abca5d421402d781199c984019df288b0a Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 19:29:25 +0800
Subject: [PATCH 22/76] docs(offline): document finite-data workflows
---
.agents/knowledge/architecture.md | 76 ++++----
.agents/knowledge/constraints.md | 58 +++++-
.agents/knowledge/philosophy.md | 5 +-
.../knowledge/topics/adapter_conventions.md | 49 +++++
.agents/knowledge/topics/fix_patterns.md | 104 +++++++++++
.agents/knowledge/topics/samplers.md | 19 +-
.agents/skills/ff-new-algorithm/SKILL.md | 96 ++++++++--
AGENTS.md | 10 +-
README.md | 39 +++-
examples/README.md | 16 +-
examples/data/offline_dpo_sd3_5/train.jsonl | 2 +
examples/data/sft_sd3_5/train.jsonl | 2 +
examples/offline_dpo/lora/sd3_5/default.yaml | 72 ++++++++
examples/sft/lora/sd3_5/default.yaml | 70 ++++++++
guidance/algorithms.md | 91 +++++++++-
guidance/datasets.md | 169 +++++++++++++++++-
guidance/new_model.md | 78 +++++++-
guidance/workflow.md | 160 ++++++++++++++---
18 files changed, 994 insertions(+), 122 deletions(-)
create mode 100644 examples/data/offline_dpo_sd3_5/train.jsonl
create mode 100644 examples/data/sft_sd3_5/train.jsonl
create mode 100644 examples/offline_dpo/lora/sd3_5/default.yaml
create mode 100644 examples/sft/lora/sd3_5/default.yaml
diff --git a/.agents/knowledge/architecture.md b/.agents/knowledge/architecture.md
index d8418b396..8496d9f7c 100644
--- a/.agents/knowledge/architecture.md
+++ b/.agents/knowledge/architecture.md
@@ -21,7 +21,7 @@
└──┬───┬───┬───┘ └──┬───┬───┬──┘ └──┬───┬───┬───┘
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
- GRPO NFT AWM Flux SD3 Wan PickScore CLIP OCR
+ GRPO SFT DPO Flux SD3 Wan PickScore CLIP OCR
```
### Key Dependency Rules
@@ -42,51 +42,29 @@
---
-## Six-Stage Training Pipeline
+## Execution Pipelines
> Authoritative reference: `guidance/workflow.md`
-```
-Stage 1: Data Preprocessing (offline, cached)
- │ GeneralDataset + adapter.preprocess_func()
- │ Text/image/video/audio → encoded tensors (prompt_embeds, image_latents, audio_features, ...)
- │ Result cached with hash fingerprint
- ▼
-Stage 2: K-Repeat Sampling
- │ Three sampler strategies (see `topics/samplers.md`):
- │ - GroupContiguousSampler (preferred, auto-selected): keeps K copies on same rank
- │ - DistributedKRepeatSampler (fallback): shuffles K copies across ranks
- │ - GroupDistributedSampler (DGPO): rank-identical prompt sequence, K/W copies per rank
- │ K = training_args.group_size
- ▼
-Stage 3: Trajectory Generation
- │ adapter.inference() — full multi-step SDE/ODE denoising
- │ Produces: generated images/videos + trajectory data (noises, log-probs)
- ▼
-Stage 4: Reward Computation
- │ RewardProcessor dispatches to Pointwise or Groupwise models
- │ Multi-reward aggregation with configurable weights
- ▼
-Stage 5: Advantage Computation
- │ AdvantageProcessor (advantage/advantage_processor.py)
- │ Communication-aware: auto-selects gather vs local path
- │ Strategies: "sum" (weighted-sum, GRPO) or "gdpo"
- ▼
-Stage 6: Policy Optimization
- │ adapter.forward() — single-step denoising for loss computation
- │ Policy gradient (GRPO) or weighted matching (NFT/AWM) or DPO preference loss
- │ Gradient update via accelerator
- ▼
- (Repeat Stages 2–6 for next epoch)
-```
+`ExecutionContract` separates acquisition (`generation` or `dataset`) from feedback
+(`runtime_reward` or `none`). `PipelineIOContract` independently owns model input/output media,
+rates, geometry, and batching.
-**Trainer methods vs stages** (each epoch, after Stage 1):
+| Composition | Driver | Optimization entry | Cycle counter |
+|---|---|---|---|
+| Generation + runtime reward | K-repeat → inference → reward → advantage | `optimize(samples)` | `rollout_iteration` |
+| Generation + no feedback | Generation/distillation path | `optimize(samples)` | `rollout_iteration` |
+| Dataset + no feedback | Finite official `DistributedSampler` traversal | `optimize_batch(batch)` | `data_epoch` |
-| Method | Stages |
-|--------|--------|
-| `sample()` | 2–3 (K-repeat batches + `adapter.inference` trajectories) |
-| `prepare_feedback()` | 4–5: reward buffer finalize, `AdvantageProcessor` |
-| `optimize()` | 6: `adapter.forward` and optimizer step (DPO: form chosen/rejected pairs at entry, then loss) |
+`optimizer_step` advances independently. A dataset epoch advances only after clean loader
+exhaustion; offline output media is decoded and encoded on the fly, while only prompt/input
+conditions enter the preprocessing cache.
+
+Exact runtime identity is built from realized prepared state. It locks optimizer/model/backend
+semantics, ordered training data, and the complete replayed evaluation path (cadence, arguments,
+per-dataset overrides, rewards, and ordered prepared loaders). Logging, checkpoint cadence, run
+budget, and resume location remain operational. Exact-state save fails before mutation on MPS
+because Accelerate does not persist the device RNG needed for exact continuation.
---
@@ -100,6 +78,8 @@ All four registries map string keys → lazy import paths. Resolution: registry
| Key | Class | Paradigm | Base Class |
|-----|-------|----------|------------|
+| `sft` | `SFTTrainer` | Decoupled, dataset | `BaseTrainer` |
+| `offline-dpo` | `OfflineDPOTrainer` | Decoupled, dataset | `BaseTrainer` |
| `grpo` | `GRPOTrainer` | Coupled | `BaseTrainer` |
| `grpo-guard` | `GRPOGuardTrainer` | Coupled | `GRPOTrainer` |
| `dppo` | `DPPOTrainer` | Coupled | `GRPOTrainer` |
@@ -128,7 +108,7 @@ All four registries map string keys → lazy import paths. Resolution: registry
| `ltx2_t2av` | `LTX2_T2AV_Adapter` | Text-to-Audio-Video |
| `ltx2_i2av` | `LTX2_I2AV_Adapter` | Image-to-Audio-Video |
| `bagel` | `BagelAdapter` | Text-to-Image & Image(s)-to-Image (T2I & I2I both batched via NaViT packing; subset-round packing handles variable I2I reference-image count, no per-sample fallback — see `topics/adapter_conventions.md`) |
-| `sensenova` | `SenseNovaAdapter` | Text-to-Image & Image(s)-to-Image (SenseNova-U1 1.0/1.5; ordered variable-count references; independent samples use B=1 prefixes rather than Bagel-style NaViT packing) |
+| `sensenova` | `SenseNovaAdapter` | Text-to-Image & Image(s)-to-Image (SenseNova-U1 1.0/1.5; ordered variable-count references remain grouped in `images` and preserve within-type order; independent samples use B=1 prefixes rather than Bagel-style NaViT packing) |
**Reward Models** (`rewards/registry.py`):
| Key | Class | Type |
@@ -162,7 +142,7 @@ Configured via the `acceleration:` block (`hparams/acceleration_args.py`): two o
- **New model adapter**: `guidance/new_model.md`, skill `/ff-new-model`, conventions `topics/adapter_conventions.md`
- **New reward model**: `guidance/rewards.md`, skill `/ff-new-reward`
-- **New algorithm**: `guidance/algorithms.md`, skill `/ff-new-algorithm`. `BaseTrainer` owns the epoch loop (`start`), timestep sampling, feedback/advantages, the optimizer step and the velocity KL; only `optimize()` is abstract. Vary behavior through `sampling_context`, `_run_training_step`, `_after_gradient_step` and `_after_optimizer_step` rather than by restating the loop. An algorithm that trains several model copies declares them in `_declare_model_variants()` (`topics/component_variants.md`).
+- **New algorithm**: `guidance/algorithms.md`, skill `/ff-new-algorithm`. `BaseTrainer` owns the cycle loop, acquisition drivers, feedback/advantages, optimizer step, and velocity KL. Generation trainers implement `optimize(samples)`; dataset trainers implement `optimize_batch(batch)`. An algorithm that trains several model copies declares them in `_declare_model_variants()` (`topics/component_variants.md`).
- **New accelerator**: subclass `acceleration/abc.py::BaseAccelerator` (declare `safety`/`stage`), register in `acceleration/registry.py`
---
@@ -175,12 +155,18 @@ Timesteps are `[0, 1000]` (scheduler scale); sigmas are `[0, 1]` (flow-matching
### Adapter Pattern (Models)
Each model adapter wraps a diffusers pipeline into the `BaseAdapter` interface:
-- `preprocess_func()` — offline encoding (Stage 1)
+- `preprocess_func()` — prompt/input-condition preprocessing and cache projection
+- `pipeline_io_contract` — model-neutral input/output modality and geometry declaration
+- `encode_output_state()` — validated on-the-fly offline target encoding through an optional codec
- `inference()` — full denoising loop (Stage 3)
- `forward()` — single-step denoising (Stage 6)
**Per-modality encoders** (`encode_prompt`, `encode_image`, `encode_video`, `encode_audio`) are no-op by default on `BaseAdapter` — override only the modalities your model consumes. `preprocess_func` dispatches to all four and skips any that return `None`, so text/image/video-only adapters need no stub overrides for unused modalities.
+Offline codecs declare logical required components without materializing them. Condition/output
+encoders share role-neutral transforms where possible, while callers retain explicit official
+posterior `sample` versus `argmax` semantics.
+
**Flat hierarchy**: All adapters inherit directly from `BaseAdapter` — never from another adapter (see constraint #12). Shared logic within a model family uses helper functions, code duplication, or mixins — not adapter subclassing.
Details: `topics/adapter_conventions.md`
@@ -297,7 +283,7 @@ Details: `topics/component_variants.md`.
```
Arguments (top-level)
├── ModelArguments # model_type, model_path, finetune_type, LoRA config
-├── TrainingArguments # Algorithm-specific (GRPO/DPO/NFT/AWM subclass)
+├── TrainingArguments # Algorithm-specific (SFT/offline-DPO/GRPO/DPO/etc.)
├── SchedulerArguments # dynamics_type, timestep_range, num_inference_steps
├── DataArguments # dataset, preprocessing, resolution, sampler_type
├── MultiRewardArguments # reward_model configs (list of RewardArguments)
diff --git a/.agents/knowledge/constraints.md b/.agents/knowledge/constraints.md
index d7548740a..0e506e9f0 100644
--- a/.agents/knowledge/constraints.md
+++ b/.agents/knowledge/constraints.md
@@ -38,25 +38,39 @@ and seed dispatch, and its immutable names must equal `trajectory_component_orde
## Training Pipeline (6–10)
-### 6. Six-Stage Pipeline Order
-The training loop executes: Data Preprocessing → K-Repeat Sampling → Trajectory Generation → Reward Computation → Advantage Computation → Policy Optimization. This order is invariant. Do not reorder or skip stages.
+### 6. Execution Contract and Stage Order
+Every trainer and its training arguments declare the same immutable acquisition/feedback
+`ExecutionContract`. Generation + runtime reward preserves Data Preprocessing → K-Repeat →
+Trajectory Generation → Reward → Advantage → Policy Optimization. Dataset + no feedback exhausts
+the finite loader through `optimize_batch()` and must not call rollout/reward stages. Do not infer
+either axis from batch fields or make it user-configurable independently of `trainer_type`.
### 7. Coupled vs Decoupled Paradigm
- **Coupled** (GRPO, GRPO-Guard, DPPO): Training timesteps are coupled with SDE-based sampling. Requires log-probability computation. Must use SDE dynamics (`Flow-SDE`, `Dance-SDE`, `CPS`).
-- **Decoupled** (DPO, NFT, AWM, DGPO, CRD): Training timesteps are decoupled from sampling. Can use any dynamics including `ODE`.
+- **Decoupled** (SFT, offline DPO, online DPO, NFT, AWM, DGPO, CRD): Training timesteps are decoupled from sampling. Can use any dynamics including `ODE`.
- **Distillation** (`diffusion-opd`): On-policy multi-teacher distillation; dynamics-agnostic (ODE or SDE) and has no reward/advantage stage.
Mixing paradigms (e.g., using `ODE` dynamics with `GRPO`) will produce incorrect gradients silently.
### 8. Component Offloading Lifecycle
-Text encoders and VAEs are loaded for Stage 1 (preprocessing), then offloaded to free VRAM before the training loop. They are reloaded for inference during sampling. Do not assume these components are always on-device.
+Text and condition encoders are loaded for preprocessing, then may be offloaded before the
+training loop. Online inference reloads its declared components. Dataset acquisition separately
+loads adapter-declared output codec components for on-the-fly target/chosen/rejected encoding;
+these output latent states must never enter the input-condition cache.
### 9. Accelerator `prepare()` Scope
-All target components (trainable **and** frozen-but-shardable) are bundled into a single `ModelBundle` (`models/model_bundle.py`) and prepared with the **optimizer** as one root via `accelerator.prepare()` — DeepSpeed (one engine) and FSDP2 (one root) cannot prepare multiple models separately. After prepare, each component is exposed as a `RoutedComponentProxy` that routes forwards through the bundle root; the optimizer/EMA/reference params still target only the `requires_grad` subset (frozen members are sharded for memory but never trained). The train dataloader uses a custom distributed sampler (`DistributedKRepeatSampler`, `GroupContiguousSampler`, or `GroupDistributedSampler`) and is NOT prepared via accelerator. Breaking this causes duplicate data or incorrect gradient accumulation.
+All target components (trainable **and** frozen-but-shardable) are bundled into a single `ModelBundle` (`models/model_bundle.py`) and prepared with the **optimizer** as one root via `accelerator.prepare()` — DeepSpeed (one engine) and FSDP2 (one root) cannot prepare multiple models separately. After prepare, each component is exposed as a `RoutedComponentProxy` that routes forwards through the bundle root; the optimizer/EMA/reference params still target only the `requires_grad` subset (frozen members are sharded for memory but never trained). Generation dataloaders use the framework's grouped samplers; dataset acquisition uses PyTorch's official `DistributedSampler`, calls `set_epoch(data_epoch)`, and requires one complete traversal per offline epoch. Neither train dataloader path is prepared via Accelerator. Breaking this causes duplicate data or incorrect gradient accumulation.
### 9a. Sampler Geometric Constraints
`DistributedKRepeatSampler` and `GroupContiguousSampler` require `M * K ≡ 0 (mod W * B * G)` where M=unique_sample_num, K=group_size, W=world_size, B=per_device_batch_size, G=gradient_step_per_epoch — **unless** `gradient_accumulation_steps` is set manually, in which case the constraint reduces to `M * K ≡ 0 (mod W * B)`. **GroupContiguousSampler** adds: `M ≡ 0 (mod W)`. **GroupDistributedSampler** (DGPO) requires: `K % W == 0` and `(W * B) % K == 0`; auto-aligned by `_align_for_group_distributed`. See `topics/samplers.md` for full details.
+Dataset acquisition does not use grouped geometry: every source weight is `1`,
+`gradient_accumulation_steps` is an explicit positive integer, and each rank's finite batch count
+must be divisible by it. Do not add batches merely to close or implicitly flush a partial
+accumulation window. PyTorch's official `DistributedSampler` remains authoritative for cross-rank
+tail handling: with `drop_last=False` it may repeat tail indices to equalize rank lengths, and one
+offline epoch means one complete traversal of that resulting finite loader.
+
### 9b. Checkpoint Save/Load Symmetry Under the Bundle
Checkpoints are written and read for **trainable members only** — components whose `target_module_map[name]` is non-empty (`adapter.trainable_component_names`). Frozen-but-shardable bundle members (e.g. Wan2.2's `transformer_2`, kept in `target_components` only to be FSDP-sharded for memory; see #9) map to `None` and are skipped by both `save_checkpoint` and `_load_lora`/`_load_full_model`. Loaders MUST iterate `trainable_component_names`, not `target_components`, or resume logs a spurious error for a per-component subdir that was never written. `resume_type='state'` restores via `accelerator.load_state` into the prepared bundle root and is therefore keyed to bundle membership — resuming into a different `target_components` / bundle composition will mismatch.
@@ -67,10 +81,18 @@ Supported distributed plans are DDP, FSDP, and DeepSpeed ZeRO-1/2. Reward model
## Base Class Interfaces (11–14)
-### 11. BaseTrainer Abstract Contract
-`BaseTrainer.__init__` expects `(accelerator, config, adapter)`. `optimize()` is the only abstract method subclasses must implement. `start()` (the shared epoch loop), `prepare_feedback()`, `compute_advantages()` and `evaluate()` are **concrete** base methods — override only to customize, and prefer the hooks (`sampling_context`, `_run_training_step`, `_after_gradient_step`, `_after_optimizer_step`) over restating the loop. The `_initialization()` method handles dataloader, optimizer, accelerator preparation, reward model loading, and `AdvantageProcessor` instantiation — do not duplicate this logic.
+### 11. BaseTrainer Execution Contract
+`BaseTrainer.__init__` expects `(accelerator, config, adapter)`. Generation trainers override
+`optimize(samples)`; dataset trainers override `optimize_batch(batch)`, and construction validates
+the hook selected by the execution contract. `start()`, acquisition drivers,
+`prepare_feedback()`, `compute_advantages()` and `evaluate()` are concrete base behavior — prefer
+the hooks over restating the loop. `_initialization()` owns dataloaders, optimizer, distributed
+preparation, rewards, and advantage processing.
-**Per-epoch hook order**: `sample()` (Stages 2–3) → `prepare_feedback()` (Stages 4–5) → `optimize()` (Stage 6). `DPOTrainer` forms chosen/rejected pairs at the **start** of `optimize()` (not in `prepare_feedback()`).
+**Acquisition hook order**: generation calls `sample()` → optional `prepare_feedback()` →
+`optimize()`; dataset acquisition iterates the official finite loader and calls optional feedback →
+`optimize_batch()`. Online `DPOTrainer` forms pairs at `optimize()` entry. Offline DPO consumes
+dataset pairs directly.
**Trainer hierarchy**: New trainers MUST inherit directly from `BaseTrainer`. The only sanctioned exceptions are strict behavioral variants of GRPO that change only the per-step loss while reusing GRPO's sampling/advantage/eval machinery: `GRPOGuardTrainer → GRPOTrainer` (adds ratio-normalization) and `DPPOTrainer → GRPOTrainer` (replaces the PPO ratio-clip with a KL trust-region mask). Trainer-to-trainer inheritance creates fragile coupling; when in doubt, inherit from `BaseTrainer` and extract shared logic into helper methods. All reward-based trainers delegate advantage computation to `self.advantage_processor.compute_advantages()`; the distillation trainer `diffusion-opd` is the exception (its `prepare_feedback()` is a no-op with no reward/advantage stage).
@@ -92,6 +114,12 @@ Note: `preprocess_func()` is a **concrete method** on `BaseAdapter` that dispatc
Breaking the signature of any of the four abstract methods (or changing the encoder return contract from "dict-or-`None`") breaks the entire training pipeline.
+Offline-capable adapters additionally declare a `PipelineIOContract`, a declaration-only
+`OutputStateCodec`, logical encoding components, and exact geometry validation. Condition and
+output paths may share role-neutral transforms, but the official posterior `sample`/`argmax`
+policy stays explicit at their semantic boundaries. Unsupported adapters declare an actionable
+`output_state_codec_unavailable_reason` and fail before heavyweight loading.
+
**Adapter hierarchy**: All model adapters MUST inherit directly from `BaseAdapter` — never from another adapter. Shared logic between adapters for the same model family should use private helper functions, code duplication, or mixins — not adapter-to-adapter inheritance. Adapter subclassing creates fragile coupling where changes to a parent adapter silently break child adapters, and makes the 4-abstract-method contract harder to verify (the 4 per-modality encoders have no-op defaults, so a fresh subclass of `BaseAdapter` is always valid; chained inheritance hides which encoder a model actually overrides).
### 13. BaseRewardModel Paradigm Split
@@ -120,7 +148,12 @@ All config dataclasses live in `hparams/`. The top-level `Arguments` aggregates
3. Any code that accesses `config.`
### 16. Algorithm-Specific Training Args
-`TrainingArguments` has algorithm-specific subclasses (`GRPOTrainingArguments`, `DPPOTrainingArguments`, `DPOTrainingArguments`, `DGPOTrainingArguments`, `NFTTrainingArguments`, `AWMTrainingArguments`, `CRDTrainingArguments`, `DiffusionOPDTrainingArguments`). The correct subclass is resolved by `get_training_args_class()` (registry in `hparams/training_args/_registry.py`). Adding a new algorithm requires adding a corresponding subclass and updating the resolver.
+`TrainingArguments` has algorithm-specific subclasses (`SFTTrainingArguments`,
+`OfflineDPOTrainingArguments`, `GRPOTrainingArguments`, `DPPOTrainingArguments`,
+`DPOTrainingArguments`, `DGPOTrainingArguments`, `NFTTrainingArguments`, `AWMTrainingArguments`,
+`CRDTrainingArguments`, `DiffusionOPDTrainingArguments`, and the multi-role distillation classes).
+The correct subclass is resolved by `get_training_args_class()`; adding an algorithm requires a
+corresponding subclass and registry entry.
### 17. YAML Config Structure
Config keys must exactly match Pydantic field names. Typos fail silently with default values. See `examples/` for canonical config templates; structure defined in `hparams/args.py`.
@@ -132,6 +165,13 @@ Config keys must exactly match Pydantic field names. Typos fail silently with de
### 18. All-Rank Synchronization Points
`accelerator.wait_for_everyone()` must be called at critical synchronization points (after preprocessing, before/after evaluation, checkpoint saving). Missing barriers cause deadlocks or race conditions.
+### 18a. Exact Resume Must Cover Replayed Evaluation
+Exact-state identity MUST lock evaluation cadence, sampling configuration, ordered realized eval
+loaders, per-dataset overrides, and eval rewards. Online checkpoints are saved before evaluation
+and replay that evaluation after resume, so treating eval as an operational control permits global
+device RNG drift. Exact-state save MUST fail before adapter or filesystem mutation on a device
+whose RNG Accelerate cannot serialize; MPS users must use model-only checkpoints.
+
### 19. FSDP CPU Efficient Loading
Distributed loading is owned by `ModelLoadCoordinator` and its `BackendLoadRuntime`. TARGET roots may use rank-zero/meta FSDP2 loading only when the adapter declares that capability. AUXILIARY and REWARD resources are materialized as full per-rank replicas; FSDP auxiliary roots receive a cached sampled-fingerprint check, while reward loading is isolated from target-only loading state. Trainer code must not manipulate FSDP loading environment variables or broadcast component weights directly.
diff --git a/.agents/knowledge/philosophy.md b/.agents/knowledge/philosophy.md
index 0cdc4398c..7744d9fd7 100644
--- a/.agents/knowledge/philosophy.md
+++ b/.agents/knowledge/philosophy.md
@@ -1,7 +1,8 @@
# Design Philosophy
-Flow-Factory is a simple, extensible RL fine-tuning framework for diffusion/flow-matching models.
-Models, algorithms, and rewards are decoupled via registries and base-class contracts.
+Flow-Factory is a simple, extensible online and offline fine-tuning framework for
+diffusion/flow-matching models. Models, algorithms, data acquisition, and rewards are decoupled via
+registries and typed contracts.
Both rollout and training run under Hugging Face Accelerate (DDP / DeepSpeed ZeRO-1-2 / FSDP
backends). The single most important invariant is **train-inference consistency**.
diff --git a/.agents/knowledge/topics/adapter_conventions.md b/.agents/knowledge/topics/adapter_conventions.md
index fc55add0c..e0c465dec 100644
--- a/.agents/knowledge/topics/adapter_conventions.md
+++ b/.agents/knowledge/topics/adapter_conventions.md
@@ -26,6 +26,35 @@ All adapters that support CFG must follow a consistent two-stage pattern. Guidan
`flux/flux2_klein.py` — `encode_prompt()` and `_forward()`.
+### Offline flow-matching guidance
+
+Finite-data SFT and offline DPO must not reuse `train.guidance_scale`: that field controls
+generation, and on a conventional CFG adapter it would turn the conditional velocity into a
+conditional/unconditional composite whenever negative embeddings are cached. These trainers
+expand the immutable `adapter.offline_training_forward_overrides` mapping into every SFT policy
+and offline-DPO policy/reference forward. The mapping is layered after both configured training
+arguments and dataset conditions, so adapter-owned model semantics always win.
+
+The base mapping sets `guidance_scale=1.0`, the conventional CFG-off point. An adapter replaces
+the complete mapping when its forward has different semantics or additional guidance branches:
+
+- Z-Image uses `guidance_scale=0.0` because its CFG threshold is `> 0.0`, with normalization and
+ truncation fixed to their neutral settings.
+- Guidance-distilled FLUX.1, FLUX.1-Kontext, and FLUX.2 use the official Diffusers training
+ condition `guidance_scale=3.5`; this is a learned model embedding, not classifier-free guidance.
+- The currently supported Flux2-Klein forward always passes `guidance=None` into its transformer,
+ so its `guidance_scale` remains conventional two-pass CFG and inherits the neutral `1.0`.
+- Wan T2V neutralizes both transformer stages with `guidance_scale=guidance_scale_2=1.0`.
+- SenseNova neutralizes text and image guidance together and disables CFG normalization.
+- Bagel replaces the base mapping with its actual `cfg_text_scale` / `cfg_img_scale` arguments;
+ it must not inherit an irrelevant `guidance_scale` key through its permissive `**kwargs`.
+
+Wan I2V and LTX2 remain behind explicit offline output-codec blockers. Wan I2V must mirror the two
+neutral Wan transformer scales before it is enabled. LTX2 must set video/audio CFG scales and
+modality scales to `1.0`, CFG rescale and STG scales to `0.0`, and neutralize its STG block
+selection with `spatio_temporal_guidance_blocks=None`. The mapping is adapter-owned model
+conditioning, never a sampling or algorithm knob.
+
### Models with model-specific CFG extensions
| Model | Extension | Notes |
@@ -177,6 +206,26 @@ LTX2 packs `[video|audio]` into one `(B, Seq, C)` sequence, so it resolves as PA
13. **SenseNova ragged I2I is per-sample, not NaViT-packed** — SenseNova-U1 1.0/1.5 accepts ordered, variable-size and variable-count reference images. Each sample's references become one variable-length NEO-Unify prefix and remain PIL across preprocessing, rollout and replay. A framework batch may contain several such samples, but `SenseNovaAdapter.inference()` / `forward()` iterate them and call `SenseNovaDenoiser` with B=1; unlike Bagel, independent samples are not concatenated into a packed attention sequence. The native model's `batch_size>1` path only expands one shared prompt/reference KV cache to generate multiple noises for the same condition and is not a ragged multi-sample batch API.
+14. **Offline forward overrides are model conditioning, not sampling CFG** — SFT and offline DPO expand the adapter's complete immutable `offline_training_forward_overrides` mapping into every policy and reference forward, after batch conditions and configured sampling arguments. Conventional CFG adapters use their CFG-off point; guidance-distilled adapters use the value expected by their learned guidance embedder; multi-branch adapters neutralize every active branch under its real forward argument names. Replace the base mapping rather than adding unrelated keys, and never infer it from cached negative embeddings or expose it as an algorithm knob.
+
+## Fix Records
+
+### Sampling CFG leaked into finite-data velocity matching
+
+- **Date**: 2026-08-28
+- **Symptom**: SFT and offline DPO could optimize a CFG-composite velocity when
+ `train.guidance_scale > 1.0`, while their target remained the conditional flow-matching
+ velocity.
+- **Root Cause**: The shared forward helper copied the generation-oriented training arguments
+ into offline forwards without an adapter-owned model-conditioning override.
+- **Fix**: Added the immutable `BaseAdapter.offline_training_forward_overrides` mapping, declared
+ complete model-specific neutral or guidance-distilled mappings (including Wan T2V, SenseNova,
+ and Bagel multi-branch CFG), and expanded it into every offline policy/reference forward after
+ configured and batch arguments.
+- **Lesson**: Generation controls and finite-data model conditioning may share a low-level
+ argument name but must have separate semantic owners.
+- **Related Constraint**: #7
+
## Cross-refs
- UP: `architecture.md` "Adapter Pattern", `constraints.md` #5 #11-12
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 00f56f916..110ffcfdb 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -86,6 +86,110 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Lesson**: Reflection is only a default. Dynamic preprocessors need an explicit cache contract.
- **Related Constraint**: N/A
+### Preserve contract-locked terminology during architecture rewrites
+- **Date**: 2026-08-28
+- **Symptom**: The SenseNova documentation regression test failed after the architecture table retained the correct behavior but dropped the contract phrase `ordered variable-count references`.
+- **Root Cause**: A broad documentation rewrite paraphrased a tested semantic distinction without checking the existing documentation contract.
+- **Fix**: `.agents/knowledge/architecture.md` now restores the exact phrase while retaining the grouped `images` and non-NaViT execution details; the documentation suite verifies both distinctions.
+- **Lesson**: Search documentation tests before rewriting architecture terminology, especially where wording distinguishes adapters with superficially similar multi-reference inputs.
+- **Related Constraint**: N/A
+
+### Distributed exact-state phases need synchronized failure and one publisher
+- **Date**: 2026-08-28
+- **Symptom**: One rank could fail exact-resume RNG/hash preflight while a peer entered `Accelerator.load_state()` and hung; concurrent saves could also pass destination preflight together and write the same staging directory.
+- **Root Cause**: Rank-local filesystem work was followed by raw barriers or core mutation without first gathering errors, and publication ownership was checked non-atomically before artifact creation.
+- **Fix**: `trainers/abc.py` now gathers all-rank errors after barrier-free path resolution, runtime preflight, and core load; commits runtime progress only after every core load succeeds; atomically elects the global publisher before core save; and keeps every filesystem claim until all publishers install their final directory. `trainers/common/runtime_identity.py` hashes FSDP wrap/state-dict topology and the full DeepSpeed batch/accumulation plan, while `runtime_state.py` validates per-device RNG topology and state installability. Runtime manifests are written only after Accelerator artifacts finish. Targeted multi-rank failure simulations cover preflight, load, and publisher races.
+- **Lesson**: A raw barrier is unsafe after rank-local I/O that can raise. Structure distributed checkpoints as preflight → synchronized error gather → backend mutation → synchronized error gather → manifest → atomic publication, acquire the publication claim before any process writes shared staging, and retain it until every filesystem reports success. Exact resume identity must cover backend topology, not only parameter names and shapes.
+- **Related Constraint**: #18
+
+### Validate nested media cardinality before flattening
+- **Date**: 2026-08-28
+- **Symptom**: Valid offline Flux1-Kontext batches shaped as `List[List[PIL]]` raised `TypeError` while checking whether a sample carried multiple condition images.
+- **Root Cause**: `_standardize_image_input()` flattened the nested batch before running its per-sample cardinality check, so the check called `len()` on each PIL image.
+- **Fix**: Flux1-Kontext now checks and warns on the original nested batch before selecting the first image, with a regression covering two offline single-image rows.
+- **Lesson**: Perform shape and cardinality validation at the boundary where that structure still exists; flattening destroys the evidence needed to validate it safely.
+- **Related Constraint**: N/A
+
+### Optional conditions require row-preserving empty sentinels
+- **Date**: 2026-08-28
+- **Symptom**: Legal batches mixing omitted and present negative prompts or condition images could reach tokenizers as `None`, lose their outer batch interpretation, or fail in an image encoder on an empty list.
+- **Root Cause**: Optionality was validated per record, but preprocessing and collation lacked homogeneous representations for a missing value inside a mixed batch.
+- **Fix**: Mixed optional negative prompts project missing values as empty strings; `is_multi_image_batch()` recognizes empty per-sample lists; Flux2/Klein emit aligned `None` latent/ID slots; and Bagel preserves empty condition-image slots. Arrow and offline-collator regressions cover the complete path.
+- **Lesson**: A batch-level optional field still needs one explicit slot per row. Normalize at the preprocessing boundary while retaining the original record identity for provenance.
+- **Related Constraint**: N/A
+
+### Preprocessing cache identity includes precision policy
+- **Date**: 2026-08-28
+- **Symptom**: Changing `component_load_dtypes` or `frozen_parameters_dtype` could reuse condition embeddings computed under a different component precision policy.
+- **Root Cause**: The offline cache fingerprint named the model but omitted the precision configuration that controls preprocessing component loading and storage.
+- **Fix**: Offline condition-cache extras now include a sorted canonical JSON representation of both dtype policies, with regressions for load-policy changes, frozen-policy changes, and mapping-order stability.
+- **Lesson**: Cache identity must include every configuration value that can change preprocessing numerics, even when that value is enforced during model loading rather than passed to the preprocessing function.
+- **Related Constraint**: #20
+
+### Batched Arrow schemas must cover later optional values
+- **Date**: 2026-08-28
+- **Symptom**: A valid optional-image dataset ordered as two prompt-only rows followed by two image-conditioned rows failed in the second `Dataset.map` chunk while casting image bytes or latent tensors to the first chunk's empty/string schema.
+- **Root Cause**: HuggingFace fixed the writer schema from the all-empty first output chunk, while Flux2 also omitted its image output keys whenever that individual chunk contained no images.
+- **Fix**: `data_utils/dataset.py` now scans real map-sized slices, dropping resolved columns from the bounded scan until it finds each later typed representative chunk; it probes those chunks only when a source column transitions from empty in the first chunk to typed later, restores Python/NumPy/torch/MPS and explicit-generator RNG state after that schema-only probe, and passes the resulting explicit `Features` through the `datasets==3.3.2`-compatible map surface. Flux2 now emits aligned image columns whenever the source image field exists; Flux2 and Flux2-Klein regressions verify identical empty-chunk output structure, a spy rejects whole-column reads, and a four-row offline cache regression covers the real Arrow path.
+- **Lesson**: A batched map's output key set and feature types are dataset-level contracts, not properties of whichever values happen to appear in the current chunk. Optional adapters must emit empty row slots consistently, and data writers must derive schemas from representative typed values before committing the first Arrow batch. A schema probe is intentionally narrow and restores RNG, but preprocessors still own their normal deterministic/cache-safe behavior; arbitrary adapter-owned mutable state is not rollback-safe.
+- **Related Constraint**: N/A
+
+### Exact resume must lock future execution and preserve acquisition boundaries
+- **Date**: 2026-08-28
+- **Symptom**: An exact checkpoint could pass preflight after objective, seed, scheduler, ordered training-data, or replayed evaluation semantics changed; a remote-rank runtime-child failure could let peers return from resume; an online resume retried its immutable source `checkpoint-N`; offline save-before-eval captured RNG that did not match the next uninterrupted epoch; and MPS could publish an exact checkpoint that its loader would always reject.
+- **Root Cause**: Resume identity stopped at physical model/optimizer/backend structure and treated RNG-consuming evaluation as operational, runtime-child commit was outside the synchronized distributed phase, one generic checkpoint/evaluation order ignored the different replay boundaries of generated versus finite-dataset acquisition, and save preflight did not reject a device RNG unsupported by Accelerate.
+- **Fix**: Runtime identity now includes trainer-extensible execution and rank-free data-contract digests derived from resolved objective/forward settings, realized training loader provenance/order/geometry, and the ordered realized evaluation path (cadence, arguments, dataset overrides, rewards, and prepared loaders). Runtime-child commit and attachment use a synchronized all-rank error phase. The first online boundary skips a save only when its resolved real path equals the exact-resume source, while still evaluating; offline boundaries evaluate before saving so exact checkpoints capture post-evaluation RNG, model-only saves retain the same observable order without claiming RNG restoration, and MPS exact save fails before adapter or filesystem mutation.
+- **Lesson**: Exact resume compatibility covers every computation and ordered data stream that can affect future state, including evaluation replay, not only the state container. Every rank-local resume mutation needs a synchronized failure boundary, checkpoint placement must match whether acquisition resumes before a rollout or after a completed data epoch, and save must not publish a state the matching load path cannot restore.
+- **Related Constraint**: #18
+
+### Multi-source schedule seeds must be process-independent
+- **Date**: 2026-08-28
+- **Symptom**: The same multi-source counts, configured seed, and epoch could produce a different source order on separate ranks or after restart.
+- **Root Cause**: `WeightedSourceBatchScheduler` seeded its generator with Python's salted `hash()` over a tuple containing a string, so the result depended on each process's `PYTHONHASHSEED`.
+- **Fix**: `data_utils/multi_source.py` now derives a domain-separated unsigned 64-bit seed from SHA-256; a subprocess regression compares schedules under distinct `PYTHONHASHSEED` values.
+- **Lesson**: Never use Python object hashes as distributed or persistent RNG seeds. Derive seeds from an explicitly versioned, stable byte representation.
+- **Related Constraint**: #9
+
+### Exact resume must include non-param-group optimizer semantics
+- **Date**: 2026-08-28
+- **Symptom**: An exact checkpoint could pass compatibility preflight after a role's gradient clipping threshold or update frequency changed.
+- **Root Cause**: Runtime identity covered realized optimizer parameter groups, but `max_grad_norm` and `update_frequency` are consumed by role optimization outside those groups.
+- **Fix**: `trainers/common/runtime_identity.py` now hashes resolved per-role optimizer arguments, including algorithm-provided defaults; runtime-identity regressions verify clipping and cadence drift change the execution digest while operational controls remain mutable.
+- **Lesson**: Exact-resume identity must cover every value that controls whether and how an optimizer update occurs, not only values serialized in optimizer parameter groups.
+- **Related Constraint**: #18
+
+### Distillation rollout cursors derive from persisted progress
+- **Date**: 2026-08-28
+- **Symptom**: DMD2, TDM, and TDM-R1 exact resumes restarted prompt acquisition from dataloader epoch zero even though the checkpoint recorded completed rollout iterations.
+- **Root Cause**: The trainers retained a live Python iterator and local dataloader epoch counter, while exact runtime state persisted only `TrainingProgress`; infinite grouped samplers never raised `StopIteration`, so the local epoch counter did not describe their real position either.
+- **Fix**: `trainers/distillation/distillation_runtime.py` now reconstructs the global consumed-batch cursor as `rollout_iteration * gradient_accumulation_steps`, uses the realized finite loader length before sampler/config fallbacks, maps the cursor to sampler epoch and intra-epoch offset, and restores Python/NumPy/torch CPU/CUDA/MPS plus explicit loader-generator RNG around iterator reconstruction and skips. Regressions compare uninterrupted and resumed real `DataLoader`, infinite grouped-sampler, and finite multi-source sequences.
+- **Lesson**: Do not serialize Python iterators or maintain a second checkpoint authority. At legal checkpoint boundaries, derive replayable loader position from persisted progress plus identity-locked realized geometry, and treat iterator construction and replayed skips as RNG-consuming side effects that must be neutralized.
+- **Related Constraint**: #18
+
+### Offline media bytes belong to the exact data identity
+- **Date**: 2026-08-28
+- **Symptom**: Replacing target, chosen, or rejected media in place left the same path-based record digest, so exact-resume preflight accepted a run whose next on-the-fly VAE inputs had changed. Input media replacement could likewise reuse stale condition embeddings.
+- **Root Cause**: Offline identities included normalized media type, path, and rate metadata but not file content.
+- **Fix**: `data_utils/offline_dataset.py` streams each unique normalized media path through SHA-256 once per source build. Input digests participate in condition IDs and cache fingerprints; supervision digests participate in full record IDs. No media, decoded pixels, or VAE latents are copied or cached.
+- **Lesson**: A path is provenance, not immutable content. Exact future-data contracts and preprocessing caches must identify external file bytes when those bytes are decoded on demand.
+- **Related Constraint**: #18
+
+### Runtime identity excludes transport-only global source IDs
+- **Date**: 2026-08-28
+- **Symptom**: Inserting or reordering an eval-only dataset renumbered training sources and caused exact-resume rejection even though the ordered training data and reward mathematics were unchanged.
+- **Root Cause**: Data identity hashed the full global name-to-ID registry and offline numeric `source_id`, which are transport metadata assigned across both train and eval entries.
+- **Fix**: `trainers/common/runtime_identity.py` now locks ordered realized training source names and loader schemas while excluding global numeric IDs. Structural and real-`Arguments` regressions verify eval-only changes preserve both execution and data digests, while training-source order/count changes remain incompatible.
+- **Lesson**: Compatibility identities should include semantic names and order, not remappable integer handles whose only purpose is runtime transport.
+- **Related Constraint**: #18
+
+### Offline epoch semantics follow the realized official sampler
+- **Date**: 2026-08-28
+- **Symptom**: Loader documentation claimed that every global sample appears exactly once and that an offline epoch is never padded, while the intentionally selected official `DistributedSampler(drop_last=False)` repeats tail indices when dataset size is not divisible by world size.
+- **Root Cause**: The design correctly defined an epoch as a complete rank-local dataloader traversal, but documentation conflated that with global sample uniqueness and with the separate prohibition on inventing batches to close a gradient-accumulation window.
+- **Fix**: Loader, workflow, dataset, sampler, and constraint documentation now preserve PyTorch's standard tail-equalization semantics and state that the framework adds no batches merely for accumulation. Existing uneven-geometry tests continue to lock official sampler behavior.
+- **Lesson**: When delegating sharding to an official sampler, define epoch semantics over its realized finite loader. Distinguish sampler-level repeated indices from optimizer-level synthetic padding.
+- **Related Constraint**: #9
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/.agents/knowledge/topics/samplers.md b/.agents/knowledge/topics/samplers.md
index ecfcd7165..c53b0e546 100644
--- a/.agents/knowledge/topics/samplers.md
+++ b/.agents/knowledge/topics/samplers.md
@@ -6,7 +6,10 @@
## Overview
-Flow-Factory uses **K-Repeat Sampling** (Stage 2 of the pipeline) to generate `K` copies of each unique prompt for group-wise advantage estimation. Three sampler implementations exist, differing in **how repeated samples are distributed across ranks**.
+Generation acquisition uses **K-Repeat Sampling** to create `K` copies of each unique prompt for
+group-wise advantage estimation. Three framework samplers differ in how repeated samples are
+distributed across ranks. Dataset acquisition is a separate path and uses PyTorch's official
+`DistributedSampler` without K-repeat geometry.
| Property | DistributedKRepeatSampler | GroupContiguousSampler | GroupDistributedSampler |
|----------|--------------------------|----------------------|------------------------|
@@ -16,6 +19,16 @@ Flow-Factory uses **K-Repeat Sampling** (Stage 2 of the pipeline) to generate `K
| **Auto-adjustment** | GCD-based rounding | LCM-based rounding (stricter) | O(√B) divisor search (`_align_for_group_distributed`) |
| **Use case** | Fallback when geometric constraints for group_contiguous are unsatisfied | Default when constraints are met (minimal communication) | DGPO — rank-identical prompt contract for local `torch.unique` |
+### Offline Dataset Sampler
+
+SFT and offline DPO always use `torch.utils.data.DistributedSampler`, call
+`set_epoch(data_epoch)`, and exhaust the finite loader once per data epoch. Every source weight is
+`1`; `gradient_accumulation_steps` is explicit; and each rank's batch count must be divisible by
+it. The loader is not passed to `Accelerator.prepare()`. With the official sampler's default
+`drop_last=False`, a non-divisible global tail is repeated deterministically to equalize rank
+lengths; the complete resulting loader traversal, rather than global sample uniqueness, defines
+the epoch.
+
---
## How Each Sampler Works
@@ -303,7 +316,7 @@ These are caught at sampler construction time. The auto-adjustment in `_align_ba
## Impact on Other Components
-- **Constraint #9 in [`../constraints.md`](../constraints.md)**: The dataloader is NOT prepared via `accelerator.prepare()` — both samplers handle distribution themselves.
+- **Constraint #9 in [`../constraints.md`](../constraints.md)**: No train dataloader is prepared via `accelerator.prepare()`; its selected grouped or official sampler owns distribution.
- **RewardProcessor**: When GroupContiguousSampler is active, groupwise rewards can be computed locally per rank. When DistributedKRepeatSampler is active, the RewardProcessor must gather group members across ranks.
- **AdvantageProcessor**: Automatically skips `accelerator.gather()` calls when `sampler_type == "group_contiguous"` (all group members already local); uses `all_reduce(count, sum, sum_sq)` for global_std (3 scalars). When `sampler_type == "distributed_k_repeat"`, packs all rewards + unique_ids into a single tensor for one `accelerator.gather()` call.
@@ -320,5 +333,5 @@ data:
## Cross-refs
- `constraints.md` #9, #9a (accelerator prepare scope, sampler geometric constraints)
-- `architecture.md` "Six-Stage Training Pipeline" (Stage 2: K-Repeat Sampling)
+- `architecture.md` "Execution Pipelines" (generation acquisition)
- `architecture.md` "Advantage Computation" (communication path depends on sampler type)
diff --git a/.agents/skills/ff-new-algorithm/SKILL.md b/.agents/skills/ff-new-algorithm/SKILL.md
index 83ff6d7f6..ecb46f37f 100644
--- a/.agents/skills/ff-new-algorithm/SKILL.md
+++ b/.agents/skills/ff-new-algorithm/SKILL.md
@@ -1,18 +1,21 @@
---
name: ff-new-algorithm
-description: "Complete workflow for adding a new RL training algorithm. Covers paradigm selection, TrainingArguments subclass, trainer implementation, registry, example config, and verification. Trigger: 'add algorithm', 'new trainer', 'new training method', 'implement algorithm'."
+description: "Complete workflow for adding an online or offline training algorithm. Covers execution-contract and paradigm selection, TrainingArguments subclass, trainer implementation, registry, example config, and verification. Trigger: 'add algorithm', 'new trainer', 'new training method', 'implement algorithm'."
---
-# New RL Algorithm Integration
+# New Training Algorithm Integration
> **Authoritative reference**: `guidance/algorithms.md`
## Prerequisites
Determine your algorithm's characteristics:
+- **Acquisition**: Generated rollouts or a finite dataset? (`generation` / `dataset`)
+- **Feedback**: Runtime reward/advantage or none? (`runtime_reward` / `none`)
- **Paradigm**: Coupled or Decoupled? (`constraints.md` #7)
- **Dynamics**: Which SDE/ODE formulation? (`Flow-SDE`, `Dance-SDE`, `CPS`, `ODE`)
-- **Advantage**: How are advantages computed from rewards? (Most algorithms can delegate to `AdvantageProcessor`)
+- **Supervision**: Prompt-only generation, demonstrations, preference pairs, or a new typed record?
+- **Advantage**: If feedback is enabled, how are advantages computed? (Most reward-based algorithms can delegate to `AdvantageProcessor`)
- **Loss**: What is the policy optimization objective?
## Phase 1: Design
@@ -20,11 +23,25 @@ Determine your algorithm's characteristics:
1. **Study existing implementations**:
- Coupled example: `trainers/rl/grpo.py` (GRPO)
- Decoupled example: `trainers/rl/nft.py` (DiffusionNFT) or `trainers/rl/awm.py` (AWM)
+ - Finite demonstration example: `trainers/offline/sft.py` (SFT)
+ - Finite preference example: `trainers/offline/offline_dpo.py` (offline DPO)
2. **Identify what's shared vs unique** (`constraints.md` #11):
- - Shared: the epoch loop (`BaseTrainer.start`), data loading, reward computation,
- `AdvantageProcessor`, `prepare_feedback`, `compute_advantages`, adapter interface, checkpoint logic
+ - Shared: the cycle loop (`BaseTrainer.start`), acquisition dispatch, progress counters,
+ adapter interface, checkpoint/eval boundaries, role optimization, and exact-resume identity
+ - Conditional: runtime rewards, `AdvantageProcessor`, `prepare_feedback`, and
+ `compute_advantages` exist only when the feedback contract requests them
- Unique: the loss function and the algorithm-specific hyperparameters. Never restate the loop
- - Per-epoch hook order: `sample()` → `prepare_feedback()` → `optimize()` (see `guidance/workflow.md`)
+ - Generation hook order: `sample()` → optional `prepare_feedback()` → `optimize()`
+ - Dataset hook order: official finite loader traversal → `optimize_batch(batch)`
+3. **Declare one immutable execution contract**:
+ - Online RL: `ONLINE_EXECUTION_CONTRACT` (`generation + runtime_reward`)
+ - Generation without rewards: `ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT`
+ - Finite offline training: `OFFLINE_EXECUTION_CONTRACT` (`dataset + none`)
+
+Keep execution semantics orthogonal to `PipelineIOContract`. The algorithm owns how examples are
+acquired and optimized; the adapter owns accepted input/output media, geometry, and output-state
+encoding. A new offline record shape belongs in the typed data layer, never in model-specific loss
+branches.
## Phase 2: Configuration
@@ -95,25 +112,30 @@ from .training_args import MyAlgoTrainingArguments
### Step 3 — Create Trainer Class
+For a generated online algorithm:
+
```python
-# src/flow_factory/trainers/rl/my_algo.py
+# src/flow_factory/trainers/rl/my_online_algo.py
+from ...contracts import ONLINE_EXECUTION_CONTRACT
from ..abc import BaseTrainer
from ..registry import register_trainer
-@register_trainer('my_algo')
-class MyAlgoTrainer(BaseTrainer):
- """My custom RL algorithm trainer."""
+@register_trainer("my-online-algo")
+class MyOnlineAlgoTrainer(BaseTrainer):
+ """My generated-acquisition algorithm."""
+
+ execution_contract = ONLINE_EXECUTION_CONTRACT
- # Do NOT define start(). BaseTrainer.start() owns the epoch loop: reseed, checkpoint on
- # save_freq, evaluate on eval_freq, _run_training_step(), ema_step, _after_optimizer_step.
- # evaluate(), prepare_feedback() and compute_advantages() are likewise CONCRETE base
- # methods. optimize() is the only abstract one.
+ # Do NOT define start(). BaseTrainer.start() owns the acquisition loop: reseed,
+ # periodic boundaries, acquisition dispatch, EMA, and _after_acquisition_cycle().
+ # evaluate(), prepare_feedback(), and compute_advantages() are concrete base methods.
+ # A generation trainer implements sample() and optimize(samples).
#
# Vary behavior through hooks instead of restating the loop:
# sampling_context() - wrap the rollout (e.g. install a snapshot's weights)
# _run_training_step() - replace the sample -> feedback -> optimize middle
# _after_gradient_step() - run right after each optimizer step
- # _after_optimizer_step() - run once per epoch, after the EMA step
+ # _after_acquisition_cycle() - run once per rollout iteration or data epoch
# _declare_model_variants() - declare several trainable copies (see component_variants.md)
def sample(self):
@@ -129,8 +151,38 @@ class MyAlgoTrainer(BaseTrainer):
pass
```
-> **Note**: `AdvantageProcessor` is auto-instantiated in `BaseTrainer._init_reward_model()`.
-> Reward-based trainers delegate via `self.advantage_processor.compute_advantages()` — see `architecture.md` "Advantage Computation". (Pure-distillation trainers like `diffusion-opd` skip rewards/advantages with a no-op `prepare_feedback()`.)
+For a finite offline algorithm:
+
+```python
+# src/flow_factory/trainers/offline/my_offline_algo.py
+from ...contracts import OFFLINE_EXECUTION_CONTRACT
+from ..abc import BaseTrainer
+from ..registry import register_trainer
+
+@register_trainer("my-offline-algo")
+class MyOfflineAlgoTrainer(BaseTrainer):
+ """My finite-dataset algorithm."""
+
+ paradigm = "decoupled"
+ execution_contract = OFFLINE_EXECUTION_CONTRACT
+
+ def _build_train_dataloader(self):
+ """Build a finite official-DistributedSampler loader for one typed schema."""
+ # Reuse build_offline_train_dataloader when the supervision type matches;
+ # otherwise extend the typed schema/collator first.
+ ...
+
+ def optimize_batch(self, batch):
+ """Apply one gradient-accumulation microstep from a dataset batch."""
+ # Decode output media in the dataset and encode it on demand through
+ # adapter.encode_output_state(); never add target VAE latents to the cache.
+ ...
+```
+
+> **Note**: `AdvantageProcessor` is relevant only to `runtime_reward` feedback.
+> Reward-based trainers delegate via `self.advantage_processor.compute_advantages()` — see
+> `architecture.md` "Advantage Computation". `none` feedback bypasses rewards structurally; do not
+> emulate that by overriding reward methods with incidental no-ops.
### Step 4 — Register in Trainer Registry
@@ -188,9 +240,12 @@ optimizers:
- [ ] `MyAlgoTrainingArguments` correctly parsed from YAML
- [ ] `get_training_args_class('my_algo')` returns correct subclass
- [ ] `get_trainer_class('my_algo')` loads `MyAlgoTrainer`
-- [ ] Training runs end-to-end for ≥2 epochs without errors
+- [ ] `execution_contract` matches the argument class and implemented optimization hook
+- [ ] Training runs end-to-end for ≥2 acquisition cycles without errors
+- [ ] Dataset acquisition defines one epoch as one complete finite dataloader traversal
- [ ] Loss values are numerically reasonable (not NaN, decreasing)
-- [ ] Rewards improve over training
+- [ ] Rewards improve over training when feedback is `runtime_reward`
+- [ ] Offline supervision media is encoded on the fly and excluded from preprocessing caches
- [ ] Checkpoint save/load works correctly
- [ ] Works with at least two different model adapters
- [ ] Coupled algorithms only use SDE dynamics
@@ -206,3 +261,6 @@ optimizers:
6. **Reimplementing advantage gather/scatter** — use `self.advantage_processor.compute_advantages()` instead; it handles both sampler topologies automatically
7. **Extending `GRPOTrainer` unnecessarily** — unless your algorithm extends GRPO's PPO-clipped loss, extend `BaseTrainer` directly (as NFT and AWM do)
8. **Optimizer-time CFG without `get_preprocess_guidance_scale()`** — if your algorithm calls `adapter.forward(guidance_scale=X)` where X > 1.0 but `training_args.guidance_scale` ≤ 1.0, negative prompts won't be encoded at preprocessing time and CFG silently falls back to no-CFG. Override `get_preprocess_guidance_scale()` in your TrainingArguments subclass to return `max(guidance_scale, your_optimize_cfg)`. See DGPO's `kl_cfg` for a real example.
+9. **Inferring online/offline behavior from batch keys** — declare `ExecutionContract`; keep acquisition and feedback independent from the model I/O schema.
+10. **Using `optimize()` for finite data** — dataset acquisition calls `optimize_batch(batch)` and advances `data_epoch` only after clean loader exhaustion.
+11. **Caching target/chosen/rejected latents** — cache prompt/input conditions only; output media is decoded and encoded on demand through the adapter output codec.
diff --git a/AGENTS.md b/AGENTS.md
index 06cbb0fa3..ce79bf0e8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,9 +2,9 @@
## Project Overview
-Flow-Factory is a unified **online RL fine-tuning framework** for diffusion/flow-matching models. It provides a modular architecture where trainers, model adapters, and reward models are independently extensible via a registry-based plugin system.
+Flow-Factory is a unified **online and offline fine-tuning framework** for diffusion/flow-matching models. It provides a modular architecture where trainers, model adapters, data acquisition, and reward models are independently extensible through typed contracts and registries.
-- **Algorithms**: GRPO, GRPO-Guard, DPPO, DPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1
+- **Algorithms**: SFT, offline DPO, online DPO, GRPO, GRPO-Guard, DPPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1
- **Models**: FLUX.1 (+Kontext), FLUX.2 (+Klein), SD3.5, Qwen-Image (+Edit-Plus), Z-Image, Wan2 (T2V/I2V), LTX2 (T2AV/I2AV), Bagel, SenseNova-U1 (1.0/1.5; T2I + ordered multi-reference I2I)
- **Rewards**: PickScore (+Rank), CLIP, CLAP, ImageBind, OCR, GenEval/GenEval2, HPSv2, VLM-Evaluate, rational-rewards, and custom rewards
- **Python**: >=3.10 | **PyTorch**: >=2.6.0 | **License**: Apache-2.0
@@ -59,8 +59,8 @@ See `.agents/knowledge/architecture.md` "Module Dependency Graph" for full detai
| Document | Purpose |
|----------|---------|
-| `guidance/workflow.md` | 6-stage training pipeline with code examples |
-| `guidance/algorithms.md` | All algorithms (GRPO, GRPO-Guard, DPPO, DPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1) deep dive |
+| `guidance/workflow.md` | Unified generation/dataset acquisition contracts plus the online 6-stage pipeline |
+| `guidance/algorithms.md` | All algorithms (SFT, offline DPO, GRPO, GRPO-Guard, DPPO, online DPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1) deep dive |
| `guidance/rewards.md` | Reward system design, custom model creation |
| `guidance/new_model.md` | Step-by-step model adapter integration |
| `guidance/acceleration.md` | Acceleration plugin layer (compile, attention backend, feature caching) |
@@ -76,7 +76,7 @@ Skills follow the [Agent Skills](https://agentskills.io) open standard. Each ski
| `/ff-review` | Pre-commit code review | Before committing changes |
| `/ff-new-model` | Model adapter integration | Adding support for a new diffusion model |
| `/ff-new-reward` | Reward model integration | Adding a new reward function |
-| `/ff-new-algorithm` | RL algorithm integration | Adding a new training algorithm |
+| `/ff-new-algorithm` | Online/offline algorithm integration | Adding a new training algorithm |
### Quick Decision Guide
diff --git a/README.md b/README.md
index 60f43ec0a..8e0b9ce45 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Flow-Factory
- Easy Reinforcement Learning for Diffusion and Flow-Matching Models
+ Unified Online RL and Offline Fine-Tuning for Diffusion and Flow-Matching Models
# 🔥 News
@@ -45,6 +45,7 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
- [Quick Start Example](#quick-start-example)
- [Guidance](#-guidance)
- [Dataset](#-dataset)
+ - [Offline SFT and Preference Data](#offline-sft-and-preference-data)
- [Text-to-Image & Text-to-Video](#text-to-image--text-to-video)
- [Image-to-Image & Image-to-Video](#image-to-image--image-to-video)
- [Reward Model](#-reward-model)
@@ -96,6 +97,12 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
> To support new models, see [Guidance/New Model](guidance/new_model.md).
+> **Offline output support:** SFT and offline DPO currently support `sd3-5`, `flux1`,
+> `flux1-kontext`, `flux2`, `flux2-klein`, `qwen-image`, `qwen-image-edit-plus`, `z-image`,
+> `bagel`, `sensenova`, and `wan2_t2v`. Wan I2V, LTX2, and MiniMax H3 fail fast on their
+> currently unresolved output/condition or audio-video semantics. See the
+> [offline model matrix](guidance/datasets.md#offline-model-support).
+
> **MiniMax H3 status:** the T2VA debug and
> [native-quality FSDP2](examples/grpo/lora/minimax_h3_t2va/quality_720p_fsdp2.yaml)
> paths are real-weight
@@ -107,9 +114,11 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
# 💻 Supported Algorithms
-| Algorithm | `trainer_type` | Paper |
+| Algorithm | `trainer_type` | Reference / objective |
|----------------|----------------|-------|
-| DPO | dpo | [Diffusion-DPO](https://arxiv.org/abs/2311.12908) |
+| SFT | sft | Supervised flow matching over V2 demonstrations |
+| Offline DPO | offline-dpo | [Diffusion-DPO](https://arxiv.org/abs/2311.12908) over V2 preference pairs |
+| Online DPO | dpo | [Diffusion-DPO](https://arxiv.org/abs/2311.12908) with generated, reward-ranked pairs |
| GRPO | grpo | [Flow-GRPO](https://arxiv.org/abs/2505.05470) / [Dance-GRPO](https://arxiv.org/abs/2505.07818) |
| DiffusionNFT | nft | [DiffusionNFT](https://arxiv.org/abs/2509.16117) |
| AWM | awm | [Advantage Weighted Matching](https://arxiv.org/abs/2509.25050) |
@@ -186,6 +195,13 @@ Start training with the following simple command:
ff-train examples/grpo/lora/flux1/default.yaml
```
+Offline smoke recipes use strict V2 manifests and require no training reward model:
+
+```bash
+ff-train examples/sft/lora/sd3_5/default.yaml
+ff-train examples/offline_dpo/lora/sd3_5/default.yaml
+```
+
# 📖 Guidance
We provide a set of guidance documents to help you understand the framework and extend it. For a comprehensive understanding of the framework's design and motivation, refer to our [technique report](https://arxiv.org/abs/2602.12529).
@@ -193,7 +209,7 @@ We provide a set of guidance documents to help you understand the framework and
| Document | Description |
|---|---|
| [Workflow](guidance/workflow.md) | End-to-end training pipeline: the overall stages from data preprocessing to policy optimization |
-| [Algorithms](guidance/algorithms.md) | Supported algorithms (GRPO, GRPO-Guard, DPPO, DiffusionNFT, AWM, DPO, DGPO, CRD, DiffusionOPD, DMD2, TDM, TDM-R1) and their configurations |
+| [Algorithms](guidance/algorithms.md) | Supported online RL, SFT, offline DPO, and distillation algorithms and their configurations |
| [Rewards](guidance/rewards.md) | Reward model system: built-in models, custom rewards, and remote reward servers |
| [Datasets](guidance/datasets.md) | Dataset schemas, media paths, and ordered-reference inputs |
| [New Model](guidance/new_model.md) | How to add support for a new Diffusion/Flow-Matching model |
@@ -214,6 +230,21 @@ The unified structure of dataset is:
|----|---| ...
```
+## Offline SFT and Preference Data
+
+SFT and offline DPO use strict JSONL with `schema_version: 2`. Public media objects always use the
+`type` discriminator; `kind` is not accepted in V2:
+
+```jsonl
+{"schema_version":2,"input":{"prompt":"A clean poster.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"targets/poster.png"}]}},"metadata":{}}
+{"schema_version":2,"input":{"prompt":"A clean poster.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"pairs/chosen.png"}]},"rejected":{"media":[{"type":"image","path":"pairs/rejected.png"}]}},"metadata":{}}
+```
+
+Prompt and input-condition encodings are cached. Target, chosen, and rejected media are decoded and
+encoded on the fly; their VAE latents are never stored in the preprocessing cache. One offline
+epoch is one complete dataloader traversal sharded by PyTorch's official `DistributedSampler`. See the
+[dataset guide](guidance/datasets.md#offline-v2-records) for the full schema and cadence rules.
+
## Text-to-Image & Text-to-Video
For text-to-image and text-to-video tasks, the only required input is the **prompt** in plain text format. Use `train.txt` and `test.txt` (optional) with following format:
diff --git a/examples/README.md b/examples/README.md
index 7ae385850..29e5d8e80 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -10,7 +10,7 @@ examples/{algorithm}/{finetune_type}/{model_type}/{variant}.yaml
| Level | Description | Examples |
|-------|-------------|---------|
-| `algorithm` | Training algorithm | `grpo`, `dppo`, `nft`, `awm`, `dgpo`, `dpo`, `crd`, `opd`, `dmd2`, `tdm`, `tdm_r1` |
+| `algorithm` | Training algorithm | `sft`, `offline_dpo`, `grpo`, `dppo`, `nft`, `awm`, `dgpo`, `dpo`, `crd`, `opd`, `dmd2`, `tdm`, `tdm_r1` |
| `finetune_type` | Parameter-efficient or full | `lora`, `full` |
| `model_type` | Model family (underscore-separated) | `flux1`, `sd3_5`, `wan21`, `ltx2` |
| `variant` | Config variant | `default.yaml`, `nocfg.yaml`, `t2v.yaml` |
@@ -24,6 +24,20 @@ examples/{algorithm}/{finetune_type}/{model_type}/{variant}.yaml
ff-train examples/grpo/lora/flux1/default.yaml
```
+## Offline examples
+
+- [`sft` with SD3.5](sft/lora/sd3_5/default.yaml) consumes V2
+ `demonstration` records from [`examples/data/sft_sd3_5`](data/sft_sd3_5/train.jsonl).
+- [`offline-dpo` with SD3.5](offline_dpo/lora/sd3_5/default.yaml) consumes V2
+ `preference` records from
+ [`examples/data/offline_dpo_sd3_5`](data/offline_dpo_sd3_5/train.jsonl).
+
+The two tiny manifests reuse repository images so their paths resolve without a separate dataset
+download. They are configuration and smoke-test fixtures, not quality-training datasets. Offline
+training requires an explicit integer `gradient_accumulation_steps`; the number of rank-local
+dataloader batches must be divisible by it. See the [dataset guide](../guidance/datasets.md#offline-v2-records)
+for the production schema and media requirements.
+
## DMD2 and TDM
- [`dmd2` SD3.5 OCR recipe](dmd2/lora/sd3_5/ocr.yaml) — validated in a
diff --git a/examples/data/offline_dpo_sd3_5/train.jsonl b/examples/data/offline_dpo_sd3_5/train.jsonl
new file mode 100644
index 000000000..f8c47b544
--- /dev/null
+++ b/examples/data/offline_dpo_sd3_5/train.jsonl
@@ -0,0 +1,2 @@
+{"schema_version":2,"input":{"prompt":"Render a clean Flow-Factory emblem.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"../../../assets/logo-no-bg.png"}]},"rejected":{"media":[{"type":"image","path":"../../../assets/wandb_metrics.png"}]}},"metadata":{"fixture":"repository-images"}}
+{"schema_version":2,"input":{"prompt":"Render an experiment dashboard.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"../../../assets/wandb_images.png"}]},"rejected":{"media":[{"type":"image","path":"../../../assets/logo.png"}]}},"metadata":{"fixture":"repository-images"}}
diff --git a/examples/data/sft_sd3_5/train.jsonl b/examples/data/sft_sd3_5/train.jsonl
new file mode 100644
index 000000000..0e4b77fc1
--- /dev/null
+++ b/examples/data/sft_sd3_5/train.jsonl
@@ -0,0 +1,2 @@
+{"schema_version":2,"input":{"prompt":"Render a clean Flow-Factory emblem.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"../../../assets/logo-no-bg.png"}]}},"metadata":{"fixture":"repository-image"}}
+{"schema_version":2,"input":{"prompt":"Render an experiment dashboard.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"../../../assets/wandb_images.png"}]}},"metadata":{"fixture":"repository-image"}}
diff --git a/examples/offline_dpo/lora/sd3_5/default.yaml b/examples/offline_dpo/lora/sd3_5/default.yaml
new file mode 100644
index 000000000..1a1698785
--- /dev/null
+++ b/examples/offline_dpo/lora/sd3_5/default.yaml
@@ -0,0 +1,72 @@
+# Single-process offline-DPO smoke recipe over a V2 preference manifest.
+# Replace the tiny repository-image dataset with real preference pairs for quality runs.
+launcher: "accelerate"
+config_file: null
+num_processes: 1
+main_process_port: 29501
+mixed_precision: "bf16"
+
+data:
+ datasets:
+ - name: offline_preferences
+ dataset_dir: "examples/data/offline_dpo_sd3_5"
+ train:
+ weight: 1 # Offline epochs require unit source weights and full traversal.
+ max_dataset_size: 2
+ enable_preprocess: true
+ preprocessing_batch_size: 2
+ dataloader_num_workers: 0
+ force_reprocess: false
+ cache_dir: "~/.cache/flow_factory/datasets"
+ sampler_type: "auto" # Offline training selects torch DistributedSampler.
+
+model:
+ finetune_type: "lora"
+ lora_rank: 16
+ lora_alpha: 16
+ target_modules: "default"
+ model_name_or_path: "stabilityai/stable-diffusion-3.5-medium"
+ model_type: "sd3-5"
+ resume_path: null
+ resume_type: null
+
+log:
+ run_name: "sd3_5_offline_dpo_smoke"
+ project: "Flow-Factory"
+ logging_backend: "none"
+ save_dir: "saves/"
+ save_freq: 0
+ save_model_only: true
+
+train:
+ trainer_type: "offline-dpo"
+ max_epochs: 1 # One epoch is one complete dataloader traversal.
+ resolution: 512
+ guidance_scale: 1.0
+ per_device_batch_size: 1
+ gradient_accumulation_steps: 1
+ beta: 2000.0
+ weighting_scheme: "logit_normal" # Options: "logit_normal", "uniform"
+ num_train_timesteps: 1 # Shared chosen/rejected Monte Carlo time terms.
+ timestep_range: 0.99
+ time_shift: 1.0
+ logit_mean: 0.0
+ logit_std: 1.0
+ ref_param_device: "cpu"
+ ema_decay: 0.0
+ enable_gradient_checkpointing: false
+ seed: 42
+
+scheduler:
+ dynamics_type: "ODE"
+
+eval:
+ eval_freq: 0
+
+optimizers:
+ - name: default
+ learning_rate: 1.0e-5
+ weight_decay: 1.0e-4
+ betas: [0.9, 0.999]
+ eps: 1.0e-8
+ max_grad_norm: 1.0
diff --git a/examples/sft/lora/sd3_5/default.yaml b/examples/sft/lora/sd3_5/default.yaml
new file mode 100644
index 000000000..2d07fa263
--- /dev/null
+++ b/examples/sft/lora/sd3_5/default.yaml
@@ -0,0 +1,70 @@
+# Single-process SFT smoke recipe over a V2 demonstration manifest.
+# Replace the tiny repository-image dataset with a real training corpus for quality runs.
+launcher: "accelerate"
+config_file: null
+num_processes: 1
+main_process_port: 29500
+mixed_precision: "bf16"
+
+data:
+ datasets:
+ - name: offline_demonstrations
+ dataset_dir: "examples/data/sft_sd3_5"
+ train:
+ weight: 1 # Offline epochs require unit source weights and full traversal.
+ max_dataset_size: 2
+ enable_preprocess: true
+ preprocessing_batch_size: 2
+ dataloader_num_workers: 0
+ force_reprocess: false
+ cache_dir: "~/.cache/flow_factory/datasets"
+ sampler_type: "auto" # Offline training selects torch DistributedSampler.
+
+model:
+ finetune_type: "lora"
+ lora_rank: 16
+ lora_alpha: 16
+ target_modules: "default"
+ model_name_or_path: "stabilityai/stable-diffusion-3.5-medium"
+ model_type: "sd3-5"
+ resume_path: null
+ resume_type: null
+
+log:
+ run_name: "sd3_5_sft_smoke"
+ project: "Flow-Factory"
+ logging_backend: "none"
+ save_dir: "saves/"
+ save_freq: 0
+ save_model_only: true
+
+train:
+ trainer_type: "sft"
+ max_epochs: 1 # One epoch is one complete dataloader traversal.
+ resolution: 512
+ guidance_scale: 1.0
+ per_device_batch_size: 1
+ gradient_accumulation_steps: 1
+ weighting_scheme: "logit_normal" # Options: "logit_normal", "uniform"
+ num_train_timesteps: 1 # Monte Carlo terms averaged inside each microbatch.
+ timestep_range: 0.99
+ time_shift: 1.0
+ logit_mean: 0.0
+ logit_std: 1.0
+ ema_decay: 0.0
+ enable_gradient_checkpointing: false
+ seed: 42
+
+scheduler:
+ dynamics_type: "ODE"
+
+eval:
+ eval_freq: 0
+
+optimizers:
+ - name: default
+ learning_rate: 1.0e-5
+ weight_decay: 1.0e-4
+ betas: [0.9, 0.999]
+ eps: 1.0e-8
+ max_grad_norm: 1.0
diff --git a/guidance/algorithms.md b/guidance/algorithms.md
index a6e99380c..67c2bca26 100644
--- a/guidance/algorithms.md
+++ b/guidance/algorithms.md
@@ -15,6 +15,10 @@
- [DPPO](#dppo)
+- [SFT](#sft)
+
+- [Offline DPO](#offline-dpo)
+
- [DPO](#dpo)
- [DGPO](#dgpo)
@@ -37,12 +41,26 @@
## Overview
-Flow-Factory provides unified implementations of state-of-the-art RL algorithms for flow-matching models. All algorithms share the same model adapter and reward interfaces, enabling direct comparison under controlled conditions.
+Flow-Factory provides unified online RL, distillation, and offline objectives for flow-matching
+models. Algorithms share model and dataset contracts while keeping objective-specific policy
+updates independent.
+
+Two execution dimensions are separate from the mathematical paradigm:
+
+| Dimension | Values | Meaning |
+|---|---|---|
+| Acquisition | `generation`, `dataset` | Generate a rollout collection or fetch every batch from a finite dataloader. |
+| Feedback | `runtime_reward`, `none` | Run reward/advantage processing or optimize without runtime feedback. |
+
+GRPO and online DPO use `generation + runtime_reward`. Generation-based distillation uses
+`generation + none`. SFT and offline DPO use `dataset + none`: their sampling stage is the dataset
+acquisition driver, so `adapter.inference()` and `sample()` are not called for training. This keeps
+online/offline selection out of model adapters and loss functions.
At a high level, the supported algorithms fall into three paradigms:
- **Coupled paradigm (GRPO and variants)**: Training timesteps are coupled with the SDE-based sampling dynamics, requiring tractable log-probability computation for policy gradient optimization.
-- **Decoupled paradigm (DPO, DiffusionNFT, AWM, DGPO, CRD, TDM-R1)**: Training timesteps are decoupled from the actual sampling dynamics, making them inherently solver-agnostic.
+- **Decoupled paradigm (SFT, offline DPO, online DPO, DiffusionNFT, AWM, DGPO, CRD, TDM-R1)**: Training timesteps are decoupled from the actual sampling dynamics, making them inherently solver-agnostic.
- **Distillation paradigm (DiffusionOPD, DMD2, TDM)**: Students match flow-matching targets. DiffusionOPD uses a teacher; DMD2 and TDM keep a fake score on one model bundle and update it before the generator.
DMD2, TDM, and TDM-R1 update fake first. TDM-R1 then updates the surrogate
@@ -190,9 +208,76 @@ train:
Like GRPO, DPPO is **coupled** and must use SDE dynamics (`Flow-SDE`, `Dance-SDE`, `CPS`). `DPPOTrainingArguments` does not inherit `GRPOTrainingArguments` (no `clip_range`) — its field set is intentionally minimal. When `kl_beta > 0`, the KL-vs-reference term is evaluated at `kl_guidance_scale`; this is reflected in `DPPOTrainingArguments.get_preprocess_guidance_scale()` so negative prompts are encoded at preprocessing whenever `kl_guidance_scale > 1.0`. Example configs: `examples/dppo/lora/{flux2_klein_base,sd3_5}/geneval2_{single,multi}.yaml`.
+## SFT
+
+SFT trains directly from V2 `demonstration` records. The target media is decoded from its source
+file and encoded to the model's clean output state on every microbatch. At each independently
+sampled flow time, the trainer noises that clean state, predicts velocity through the ordinary
+adapter `forward()` contract, and minimizes the per-sample flow-matching error. Several
+`num_train_timesteps` terms are averaged inside the microbatch before one backward pass.
+
+```yaml
+train:
+ trainer_type: sft
+ max_epochs: 4
+ per_device_batch_size: 1
+ gradient_accumulation_steps: 4
+ weighting_scheme: logit_normal # logit_normal or uniform
+ num_train_timesteps: 1
+ timestep_range: 0.99 # scalar -> (0, scalar), or [lower, upper]
+ time_shift: 1.0
+ logit_mean: 0.0
+ logit_std: 1.0
+```
+
+`max_epochs` counts complete dataloader traversals. `num_train_timesteps` is a Monte Carlo axis,
+not a hidden gradient-accumulation multiplier. SFT has no reference model and rejects training
+`rewards`; `eval_rewards` remain available for generation-based evaluation.
+
+See the [SFT configuration](../examples/sft/lora/sd3_5/default.yaml) and the
+[V2 demonstration schema](datasets.md#demonstration-supervision).
+
+## Offline DPO
+
+Offline DPO applies the Diffusion-DPO objective [[11]](#ref11) to V2 `preference` records rather
+than generating and reward-ranking samples.
+Chosen and rejected media are encoded on the fly under the same input condition. For each loss
+term they share the primary timestep, exact component-time mapping, and diffusion noise. The
+current policy and a frozen reference each produce chosen/rejected flow-matching errors; the
+shared DPO objective applies `beta` to the policy-versus-reference error delta.
+
+```yaml
+train:
+ trainer_type: offline-dpo
+ max_epochs: 1
+ per_device_batch_size: 1
+ gradient_accumulation_steps: 1
+ beta: 2000.0
+ weighting_scheme: logit_normal
+ num_train_timesteps: 1
+ timestep_range: 0.99
+ time_shift: 1.0
+ logit_mean: 0.0
+ logit_std: 1.0
+ ref_param_device: cpu
+```
+
+The reference is mandatory; `reference_free` is not implemented. LoRA runs use the base model by
+disabling trainable adapters inside the reference scope. Full-parameter runs keep the frozen
+reference snapshot on `ref_param_device`. Training rewards and online pair formation are not part
+of this path.
+
+See the [offline-DPO configuration](../examples/offline_dpo/lora/sd3_5/default.yaml), the
+[V2 preference schema](datasets.md#preference-supervision), and the current
+[offline model matrix](datasets.md#offline-model-support).
+
## DPO
-DPO (Direct Preference Optimization) [[11]](#ref11) is a **decoupled** algorithm that optimises a pairwise preference loss on flow-matching velocity targets. Instead of per-sample policy-gradient ratios, it forms chosen/rejected pairs within each group (based on per-sample advantages), then minimises a Bradley-Terry preference loss over the DSM errors of the two policies (current vs. frozen reference). To use this algorithm, set:
+The existing `dpo` trainer is **online DPO** [[11]](#ref11). It is a decoupled algorithm that generates samples,
+scores them with runtime rewards, and forms chosen/rejected pairs within each prompt group from
+the resulting advantages. It then minimizes a Bradley-Terry preference loss over the DSM errors
+of the current and frozen-reference policies. Use `offline-dpo` instead when the dataset already
+contains preference pairs. To select online DPO, set:
```yaml
train:
diff --git a/guidance/datasets.md b/guidance/datasets.md
index 94b0ae850..8edd8aac9 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -53,8 +53,163 @@ resolved against their corresponding media directory. MiniMax H3 Ref2VA is diffe
relative `references[*].path` and `references[*].audio_path` values are resolved against
`dataset_dir`; absolute paths are accepted unchanged.
+## Offline V2 records
+
+SFT and offline DPO require strict JSONL records with `"schema_version": 2`. In this release, V2 is
+a supervised offline format: every record must carry either demonstration or preference
+supervision. It is not an alternative input format for the existing online generation loader.
+V2 separates the model input from its supervision, so dataset rows remain model- and
+algorithm-neutral:
+
+```text
+record
+├── input # prompt plus optional condition media
+├── supervision # demonstration or preference
+└── metadata # optional JSON provenance; never a model input
+```
+
+Every V2 media object uses `type` as its only public discriminator. The accepted values are
+`image`, `video`, and `audio`:
+
+```json
+{"type":"image","path":"images/source.png"}
+{"type":"video","path":"videos/clip.mp4","fps":24.0}
+{"type":"audio","path":"audios/clip.wav","sample_rate":48000}
+```
+
+Do not write `kind` in a V2 record. Some ordered-reference adapters still consume a validated
+legacy `kind` mapping internally; the V2 condition projection creates that private bridge only at
+the adapter preprocessing boundary. It is not part of the public V2 schema.
+
+### Demonstration supervision
+
+One SFT row has a shared input and one target candidate:
+
+```jsonl
+{"schema_version":2,"input":{"prompt":"Restyle the source as a watercolor.","media":[{"type":"image","path":"conditions/source.png"}]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"targets/watercolor.png"}]}},"metadata":{"license":"example"}}
+```
+
+Use it with `train.trainer_type: sft`. `target.media` is an ordered sequence because a pipeline
+may eventually emit several modalities. Adapter-level codec availability and explicit blockers
+are checked before model weights are loaded. The offline loader then validates each record's exact
+output sequence, rates, input cardinality, and batch capability before condition preprocessing or
+training; adapter-specific encoded geometry is validated at the output-codec boundary.
+
+### Preference supervision
+
+One offline-DPO row shares the input across a chosen and rejected candidate:
+
+```jsonl
+{"schema_version":2,"input":{"prompt":"A clean typographic poster.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"pairs/chosen.png"}]},"rejected":{"media":[{"type":"image","path":"pairs/rejected.png"}]}},"metadata":{"annotator":"example"}}
+```
+
+Use it with `train.trainer_type: offline-dpo`. A training source must be homogeneous: every row
+must carry the supervision type required by its trainer. Prompt-only rows, mixed demonstration and
+preference rows, unknown keys, and non-V2 records fail during manifest loading.
+
+All V2 media paths are resolved against that source's `dataset_dir`; an absolute path is retained.
+Images and videos have built-in CPU decoders. Video targets require PyAV 18 or newer. There is no
+default audio target decoder yet, which is one reason the current audio-video adapters are blocked
+for offline objectives.
+
+Tiny schema-complete fixtures and configs are available for
+[SFT](../examples/sft/lora/sd3_5/default.yaml) and
+[offline DPO](../examples/offline_dpo/lora/sd3_5/default.yaml).
+
+Evaluation still uses generation acquisition, including when the trainer is SFT or offline DPO.
+Consequently, a split enabled through `data.datasets[*].eval` must use one of the legacy
+prompt/condition formats documented under [Common task formats](#common-task-formats), not a
+supervised V2 record. A single dataset directory may therefore contain a V2 `train.jsonl` and a
+legacy prompt-only `test.jsonl` without conflating their roles.
+
+### Condition cache and target lifecycle
+
+Offline preprocessing intentionally caches only the input side:
+
+```text
+V2 input
+ -> project prompt and condition media
+ -> adapter.preprocess_func under no-grad
+ -> Arrow cache of prompt/condition tensors
+
+V2 target, chosen, or rejected
+ -> decode from the source file in Dataset.__getitem__
+ -> collate decoded CPU media
+ -> adapter.encode_output_state under no-grad on every training microbatch
+ -> clean latent state for the objective
+```
+
+Target, chosen, and rejected payloads, their VAE latents, and supervision metadata are never
+written to the Arrow condition cache. There is no target-VAE preprocessing cache. This avoids a
+second large media-derived dataset on disk and keeps output geometry and posterior semantics owned
+by the adapter. The frozen VAE (or other output codec component) remains available at training
+time and performs the comparatively small on-the-fly encode; evaluation already needs the decoder.
+
+During offline dataset construction, every unique normalized input and supervision media path is
+streamed once through SHA-256, with digests memoized only for that source build. These digests are
+identity metadata: media payloads, decoded pixels, and output latents are neither copied nor cached.
+Replacing an input condition file in place therefore changes its condition identity and invalidates
+the Arrow cache automatically. Replacing target, chosen, or rejected media in place changes the
+full record identity used by exact-resume checks; supervision is still decoded afresh and requires
+no target-cache invalidation step.
+
+### Offline dataloader and epoch semantics
+
+Offline sources are concatenated once and sharded with PyTorch's official
+`torch.utils.data.DistributedSampler`, including a one-process run. The trainer calls
+`sampler.set_epoch(data_epoch)` and advances `data_epoch` only after the finite dataloader is
+exhausted successfully. Therefore one offline epoch has the standard meaning: one complete
+dataloader traversal. An exception or interruption during a partial traversal does not publish a
+completed epoch.
+
+Keep these configuration rules:
+
+```yaml
+data:
+ datasets:
+ - name: demonstrations
+ dataset_dir: "dataset/demonstrations"
+ train: {weight: 1}
+ enable_preprocess: true
+ sampler_type: auto
+train:
+ max_epochs: 4
+ per_device_batch_size: 1
+ gradient_accumulation_steps: 4
+```
+
+- Each offline source must use `train.weight: 1`; replacement weighting would make full traversal
+ stop meaning one data epoch.
+- `gradient_accumulation_steps` must be an explicit positive integer. The number of rank-local
+ batches must divide evenly by it; the framework never adds batches merely to close a partial
+ accumulation window or silently flushes one.
+- PyTorch's official `DistributedSampler` owns cross-rank tail handling. With its default
+ `drop_last=False`, it may repeat tail indices when the global dataset size is not divisible by
+ world size so every rank traverses the same number of samples. This is standard sampler behavior;
+ an offline epoch is the resulting complete dataloader traversal, not a global uniqueness claim.
+- The offline loader is already rank-sharded and is not passed to `Accelerator.prepare()`.
+- `num_train_timesteps` controls independently sampled Monte Carlo loss terms averaged inside a
+ microbatch. It does not multiply gradient accumulation or change epoch length.
+
+### Offline model support
+
+The V2 schema is broader than the codecs currently implemented by adapters. Static capability
+validation fails before heavyweight model loading when a selected pipeline cannot preserve its
+output semantics.
+
+| Offline status | Model types | Notes |
+|---|---|---|
+| Supported | `sd3-5`, `flux1`, `flux1-kontext`, `flux2`, `flux2-klein`, `qwen-image`, `qwen-image-edit-plus`, `z-image`, `bagel`, `sensenova` | Image-output codecs with adapter-specific geometry and packing. SenseNova uses the existing grouped `images` input with within-type order, not heterogeneous references. |
+| Supported | `wan2_t2v` | Video targets require `fps`; the codec resamples to configured frames/rate and samples the Wan VAE posterior on the fly. |
+| Blocked | `wan2_i2v` | Output geometry depends on the first-frame VAE latent/mask, while the current condition cache does not preserve the source pixels needed by that binder. |
+| Blocked | `ltx2_t2av`, `ltx2_i2av` | Lossless audio decode/rate metadata and exact audio-video duration alignment are not unified; I2AV also needs the pinned first-frame active mask. |
+| Blocked | `minimax-h3-t2va`, `minimax-h3-fl2va`, `minimax-h3-ref2va` | The audio-video boundary and official target-video posterior policy are not yet defined for offline targets. |
+
## Common task formats
+The following compact formats remain supported for generation acquisition. They are separate from
+strict V2 offline records and do not carry output supervision.
+
### Text-conditioned generation
Text-to-image, text-to-video, and text-to-audio-video datasets may use plain text:
@@ -201,7 +356,8 @@ directory. See the [FL2VA dataset fixture](../dataset/minimax_h3_fl2va/train.jso
### Ref2VA: `minimax-h3-ref2va`
-Ref2VA uses a non-empty ordered `"references"` array containing image, video, and audio entries:
+The existing online Ref2VA loader uses a legacy non-empty ordered `"references"` array containing
+image, video, and audio entries:
```jsonl
{"prompt":"Create a coherent scene using the references in order.","references":[{"kind":"image","path":"references/style.png"},{"kind":"video","path":"references/motion.mp4","fps":12.0},{"kind":"audio","path":"references/ambience.wav","sample_rate":16000}]}
@@ -223,6 +379,10 @@ Supported entries:
soundtrack or a separate dataset-relative `audio_path`; a video `sample_rate` override requires
`audio_path`. Unknown keys and unsupported `kind` values fail before preprocessing.
+This legacy online manifest is distinct from the strict V2 format above. A V2 record always uses
+`input.media[*].type`; offline condition projection performs any required legacy `kind` conversion
+internally.
+
See the [Ref2VA dataset fixture](../dataset/minimax_h3_ref2va/train.jsonl), its
[local fixture notes](../dataset/minimax_h3_ref2va/README.md), and the
[Ref2VA GRPO configuration](../examples/grpo/lora/minimax_h3_ref2va/default.yaml).
@@ -240,9 +400,10 @@ TXT/JSONL row
-> collate cached fields for rollout
```
-Prompt encoders, VAEs, and processors are preprocessing components. The trainer can offload them
-after cache creation because optimization consumes their cached outputs rather than decoding and
-encoding every source row again.
+Prompt encoders, condition VAEs, and processors are preprocessing components. Online RL can
+offload them after cache creation because optimization consumes cached conditions. Offline SFT and
+offline DPO reload any output-codec components declared by the adapter and encode target,
+chosen, and rejected media on the fly; those output states are never cached.
Ref2VA adds an ordered-reference path:
diff --git a/guidance/new_model.md b/guidance/new_model.md
index 3069bcafa..025686559 100644
--- a/guidance/new_model.md
+++ b/guidance/new_model.md
@@ -12,6 +12,7 @@
- [Step 5: Implement `inference()`](#step-5-implement-inference)
- [Step 6: Implement `forward()`](#step-6-implement-forward)
- [Step 7: Register the Adapter](#step-7-register-the-adapter)
+- [Advanced: Offline Output-State Encoding](#advanced-offline-output-state-encoding)
- [Advanced: Custom `preprocess_func`](#advanced-custom-preprocess_func)
- [Advanced: Pseudo-Pipeline for Non-Diffusers Models](#advanced-pseudo-pipeline-for-non-diffusers-models)
- [Data Format Conventions](#data-format-conventions)
@@ -19,7 +20,7 @@
## Overview
-Flow-Factory uses a **model adapter** pattern that wraps [diffusers](https://github.com/huggingface/diffusers) pipelines into a unified interface for RL training. Each adapter maps a diffusers pipeline to a consistent API that the training loop can call without knowing model-specific details.
+Flow-Factory uses a **model adapter** pattern that wraps [diffusers](https://github.com/huggingface/diffusers) pipelines into a unified interface for online and offline fine-tuning. Each adapter maps a diffusers pipeline to a consistent API that the training loop can call without knowing model-specific details.
The relationship is straightforward:
@@ -114,7 +115,7 @@ class MyModelSample(T2ISample):
| `I2AVSample` | Image-to-audio-video | `ImageConditionSample` subclass |
| `V2VSample` | Video-to-video | `VideoConditionSample` subclass |
-> See [`src/flow_factory/samples/samples.py`](src/flow_factory/samples/samples.py) for all available classes.
+> See [`src/flow_factory/samples/samples.py`](../src/flow_factory/samples/samples.py) for all available classes.
> **Key**: The `_shared_fields` class variable declares fields that are identical across a batch (e.g., `height`, `width`, `latent_index_map`). During `BaseSample.stack()`, shared fields take the first element instead of stacking.
@@ -192,7 +193,7 @@ class MyModelAdapter(BaseAdapter):
| `preprocessing_modules` | `['text_encoders', 'vae']` |
| `inference_modules` | `['transformer', 'vae']` |
-Override only when your model deviates — for example, [WAN-T2V](src/flow_factory/models/wan/wan2_t2v.py) models need `['text_encoders', 'vae', 'image_encoder']` for preprocessing and conditionally include `transformer_2` for inference.
+Override only when your model deviates — for example, [WAN-T2V](../src/flow_factory/models/wan/wan2_t2v.py) models need `['text_encoders', 'vae', 'image_encoder']` for preprocessing and conditionally include `transformer_2` for inference.
> **Tip**: Use `print(dict(self.pipeline.named_children()))` to discover available component names.
@@ -547,6 +548,68 @@ model:
model_name_or_path: "org/my-model-checkpoint"
```
+## Advanced: Offline Output-State Encoding
+
+Online-only adapters can keep the default `build_output_state_codec() -> None`. To support SFT or
+offline DPO, an adapter must additionally declare both sides of its pipeline and provide an
+on-the-fly output codec:
+
+1. Set a class-level `pipeline_io_contract`. It owns input media counts/order/binding, negative
+ prompt policy, the exact ordered output media sequence, rate requirements, geometry source, and
+ batch capability.
+2. Override `build_output_state_codec()` with a declaration-only codec. Its
+ `required_components` names logical runtime components such as `("vae",)`; construction must
+ not load, materialize, move, replace, or cast them.
+3. Return an `EncodedOutputState` containing a detached `LatentState`, output-derived forward and
+ decode contexts, and one exact geometry signature per sample.
+4. Override `_validate_encoded_output_geometry()` so configured, condition-derived, and
+ output-derived dimensions cannot drift silently.
+5. Declare a complete immutable `offline_training_forward_overrides` mapping whenever the base
+ `{"guidance_scale": 1.0}` contract does not describe the adapter. Offline trainers apply this
+ mapping after sampling configuration and cached batch conditions, so it owns loss-time model
+ conditioning. Conventional CFG branches must all be set to their neutral point (for example,
+ both Wan transformer scales or Bagel text/image CFG scales); guidance-distilled models instead
+ declare the explicit guidance-embedding value used by their official training recipe. Replace
+ the complete mapping so permissive `**kwargs` forwards do not receive unrelated base keys.
+
+The dataset remains responsible only for strict V2 parsing and CPU media decoding. The adapter
+owns numerical output semantics. The SFT/offline-DPO trainer calls `encode_output_state()` on every
+microbatch under `torch.no_grad`; target, chosen, and rejected latents are not preprocessing-cache
+columns. The declared output components are loaded through `ModelLoadCoordinator`, never from
+inside the codec.
+
+Condition encoding and target encoding should share role-neutral numerical transforms instead of
+duplicating VAE math. Extract helpers for pixel preprocessing, posterior extraction, latent
+normalization, patchification, IDs, and packing, then make the posterior policy an explicit
+argument:
+
+```python
+def encode_vae_image(adapter, pixels, *, sample_mode, generator=None):
+ posterior = adapter.vae.encode(pixels).latent_dist
+ latent = (
+ posterior.sample(generator=generator)
+ if sample_mode == "sample"
+ else posterior.mode()
+ )
+ return normalize_and_pack(adapter, latent)
+```
+
+The helper is role-neutral; the caller is not. Follow the official Diffusers pipeline for each
+role. Condition paths commonly use posterior `argmax`/`mode` for stable conditioning, while
+training targets use posterior `sample` and forward the caller's generator. Never merge the two
+entry points in a way that silently changes this policy. Tests should compare the shared transform
+against the pinned Diffusers helper and assert both sample/argmax behavior and generator routing.
+
+An output codec is not merely an `encode_image()` alias. It may need output-specific geometry,
+multi-component state order, active masks, rate alignment, or forward context that condition
+encoding does not own. If those semantics are not lossless, set a concrete
+`output_state_codec_unavailable_reason` so offline selection fails before downloading weights.
+
+SenseNova is an example of an important boundary: its existing condition schema uses grouped
+`images` with within-type order. Do not advertise heterogeneous ordered references merely because
+several images are accepted. The public V2 discriminator remains `type`; conversion to a legacy
+adapter-internal `kind` entry, when genuinely required, belongs only in the condition projection.
+
## Advanced: Custom `preprocess_func`
The default `preprocess_func` calls `encode_prompt`, `encode_image`, `encode_video` and `encode_audio` independently. Override it when your model requires **cross-modal preprocessing** — for example, FLUX.2 uses its text encoder to "upsample" (rewrite) prompts based on input images before encoding ([here](https://github.com/X-GenGroup/Flow-Factory/blob/main/src/flow_factory/models/flux/flux2.py#L371)):
@@ -837,9 +900,14 @@ Before submitting a new model adapter, verify:
- [ ] **`encode_audio()`** — Override only if your model consumes audio; handles `MultiAudioBatch` input format (text/image/video-only models inherit the no-op default)
- [ ] **`inference()`** — Accepts both raw and pre-encoded inputs; returns `List[Sample]`
- [ ] **`forward()`** — Single denoising step; ends with `self.scheduler.step()`; returns `SDESchedulerOutput`
+- [ ] **Pipeline I/O contract** — Declares exact input/output media, rate, geometry, and batch semantics before enabling offline training
+- [ ] **Output-state codec (when supported)** — Declaration-only logical component requirements; on-the-fly detached target encoding; exact geometry validation
+- [ ] **Offline forward overrides (when supported)** — Complete immutable adapter mapping; sampling controls never define offline loss semantics; every CFG branch is neutralized or every distilled guidance condition is explicitly pinned
+- [ ] **Role-neutral encoder math** — Condition/output paths reuse transforms but explicitly preserve official posterior `sample` versus `argmax` policy and generator routing
+- [ ] **Explicit offline blocker (when unsupported)** — `output_state_codec_unavailable_reason` names the missing lossless semantic boundary
- [ ] **Sample dataclass** — All fields without batch dimension; `_shared_fields` correctly set; custom field types are consistent (no `Tensor` vs `List[Tensor]` mixing across samples)
- [ ] **Registry entry** — Added to `_MODEL_ADAPTER_REGISTRY`
-- [ ] **Tested** — Runs at least one epoch of GRPO training without errors
+- [ ] **Tested** — Runs at least one rollout cycle for online support and one complete dataloader epoch for any declared offline support
## Component Runtime and Structured Replay
@@ -875,4 +943,4 @@ rather than replacing registered modules.
MiniMax H3 is the reference for workflow-pruned modular components and separate
video/audio trajectories. See `src/flow_factory/models/minimax_h3/` and the
-[MiniMax H3 dataset contracts](datasets.md#minimax-h3-datasets).
\ No newline at end of file
+[MiniMax H3 dataset contracts](datasets.md#minimax-h3-datasets).
diff --git a/guidance/workflow.md b/guidance/workflow.md
index 1dd42de13..7af9c133b 100644
--- a/guidance/workflow.md
+++ b/guidance/workflow.md
@@ -4,6 +4,7 @@
- [Overview](#overview)
- [Stage 1: Data Preprocessing](#stage-1-data-preprocessing)
+- [Offline Dataset Training](#offline-dataset-training)
- [Stage 2: K-Repeat Sampling](#stage-2-k-repeat-sampling)
- [Stage 3: Trajectory Generation](#stage-3-trajectory-generation)
- [Stage 4: Reward Computation](#stage-4-reward-computation)
@@ -13,11 +14,35 @@
## Overview
-Flow-Factory follows an **online RL** training paradigm for diffusion/flow-matching models. Each epoch executes a six-stage pipeline:
+Flow-Factory has one training kernel with orthogonal execution contracts. An algorithm declares
+where examples come from and whether they need runtime feedback:
+
+| Contract axis | Value | Runtime behavior |
+|---|---|---|
+| Acquisition | `generation` | Run adapter inference to create a rollout collection. |
+| Acquisition | `dataset` | Fetch and optimize every batch from a finite dataloader. |
+| Feedback | `runtime_reward` | Compute rewards and advantages before optimization. |
+| Feedback | `none` | Pass acquired examples directly to the objective. |
+
+This produces three currently useful compositions:
+
+```text
+online RL generation + runtime_reward
+generation distillation generation + none
+SFT / offline DPO dataset + none
+```
+
+Acquisition is not a model concern, and feedback is not inferred from a batch shape. The trainer,
+its algorithm-specific arguments, and the shared driver must declare the same immutable contract.
+Pipeline I/O is a separate adapter contract that validates input modalities, ordered references,
+output modality/rates, geometry ownership, and batch capability. The data schema therefore does
+not need model-specific tensor names, while the objective does not need file-format branches.
+
+The familiar online RL path still executes six stages:
```
┌─────────────────────────────────────────────────────────────────────────────────┐
-│ Flow-Factory Training Epoch │
+│ Flow-Factory Online Rollout Cycle │
│ │
│ ┌─────────────┐ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Data │ │ K-Repeat │ │ Trajectory │ │ Reward │ │
@@ -32,21 +57,24 @@ Flow-Factory follows an **online RL** training paradigm for diffusion/flow-match
└─────────────────────────────────────────────────────────────────────────────────┘
```
-The high-level training loop lives once in `BaseTrainer.start()`; no algorithm restates it:
+The high-level loop lives once in `BaseTrainer.start()`. Its acquisition driver selects either a
+generated collection or one finite dataset traversal:
```python
# src/flow_factory/trainers/abc.py — BaseTrainer.start()
def start(self):
while self.should_continue_training():
- # Reseed, checkpoint on save_freq, evaluate on eval_freq (omitted for brevity)
- self._run_training_step() # Stages 2-6, the algorithm-specific middle
- self.adapter.ema_step(step=self.epoch)
- self._after_optimizer_step()
- self.epoch += 1
+ driver.prepare_cycle(self, progress, seed=train.seed)
+ driver.run_cycle(self, progress)
+ if contract.acquisition is AcquisitionMode.GENERATION:
+ self.adapter.ema_step(step=progress.rollout_iteration)
+ # Runs once per completed rollout iteration or fully exhausted data epoch.
+ self._after_acquisition_cycle()
+ # Advance only after the selected acquisition completes successfully.
+ progress = progress.advance_acquisition(contract.acquisition, completed=True)
```
-The default `_run_training_step` is the familiar three-stage sequence, so a reward-based
-algorithm only implements `optimize()`:
+Generation acquisition retains the familiar sequence:
```python
samples = self.sample() # Stages 2 + 3
@@ -54,19 +82,24 @@ self.prepare_feedback(samples) # Stages 4 + 5 (rewards + advantages)
self.optimize(samples) # Stage 6 (DPO: pair formation + loss here)
```
-> **Note**: Stage 1 (preprocessing) runs *once* before training begins and is cached to disk. Stages 2–6 repeat every epoch. The three methods above map directly to those stages: `sample` → trajectory rollouts; `prepare_feedback` → finalize rewards from the buffer and compute advantages; `optimize` → policy update (DPO additionally forms chosen/rejected pairs at the start of `optimize` before the loss). `optimize()` is the only abstract one; vary the rest through `sampling_context`, `_run_training_step` and `_after_optimizer_step`.
+Dataset acquisition never calls `sample()` or `adapter.inference()` for training. It calls
+`optimize_batch(batch)` for every loader batch and advances the data epoch only after exhaustion.
+This is the concrete form of treating the RL sampling stage as a no-op/fetch operation for an
+offline algorithm, without teaching online trainers about offline batch formats.
## Stage 1: Data Preprocessing
-**Goal**: Encode raw text prompts (and optional images / videos / audio files) into model-ready tensor representations *before* training begins, eliminating redundant computation during the RL loop and enabling components offloading such as **text-encoder**, **image-encoder**, and **audio-encoder** (when applicable).
+**Goal**: Encode raw text prompts (and optional condition images / videos / audio files) into
+model-ready tensor representations before repeated training work, eliminating redundant condition
+encoding and enabling component offloading.
### Input / Output
| | Description |
|---|---|
-| **Input** | Raw dataset: `train.jsonl` or `train.txt` containing prompts, optional image / video / audio paths |
-| **Output** | Cached HuggingFace Dataset on disk with pre-encoded tensors (`prompt_embeds`, `prompt_ids`, `pooled_prompt_embeds`, `image_latents`, etc.) |
+| **Input** | Raw dataset containing prompts and optional condition media paths. Offline objectives use the `input` portion of strict V2 records. |
+| **Output** | Cached HuggingFace Dataset with input tensors (`prompt_embeds`, `prompt_ids`, condition latents, etc.). Output supervision is excluded. |
### How It Works
@@ -105,13 +138,21 @@ def preprocess_func(self, prompt, images, ...):
- **Cache layout**: The merged cache directory looks like `{cache_dir}/{fingerprint}/_parts/rank_{i:05d}_of_{N:05d}/cache-{fingerprint}_shard{i}of{N-1}.arrow`, plus the top-level `state.json` and `dataset_info.json`. While preprocessing is in flight, the same content lives under `{cache_dir}/{fingerprint}.tmp/`, with a `_build_meta.json` sentinel that records `num_shards` so a subsequent run with the same `num_shards` can resume from any per-rank Arrow files that were already written before a crash, while a different `num_shards` triggers a clean wipe.
- **No HF default-cache copy**: Because each `map()` call sets `cache_file_name`, HuggingFace does **not** also write a duplicate `cache-*.arrow` under `~/.cache/huggingface/datasets/...`.
- **Intelligent caching**: A hash fingerprint of `(dataset, split, max_dataset_size, preprocess_func source, preprocess_kwargs, extra_hash_strs)` (the last includes `model_type` and `model_name_or_path`) determines the cache path. Subsequent runs that match the fingerprint take the fast path without any `Dataset.map` invocation.
-- **Component offloading**: Text encoders and VAEs are loaded for preprocessing, then offloaded before the training loop to free VRAM for the denoising model.
+- **Component offloading**: Text and condition encoders can be offloaded after cache creation. An
+ offline adapter's declared output codec components are reloaded through the component lifecycle
+ for on-the-fly target encoding.
+- **No target cache**: SFT targets and offline-DPO chosen/rejected candidates are decoded per
+ dataset access and encoded per training microbatch. Target payloads, latent states, and metadata
+ never enter the Arrow condition cache.
### Configuration
```yaml
data:
- dataset: "path/to/dataset"
+ datasets:
+ - name: example
+ dataset_dir: "path/to/dataset"
+ train: {weight: 1}
enable_preprocess: true # Enable offline preprocessing
force_reprocess: false # Force re-encoding even if cache exists; essential if code is modified without changing config
preprocessing_batch_size: 16 # Batch size for encoding
@@ -119,9 +160,64 @@ data:
preprocess_parallelism: "local" # "local" = per-node parallelism (no shared FS required); "global" = cross-node (shared FS required)
```
+## Offline Dataset Training
+
+SFT and offline DPO use strict V2 JSONL manifests described in the
+[dataset guide](datasets.md#offline-v2-records). Their finite loader is constructed with PyTorch's
+official `DistributedSampler`, even for `num_replicas=1`, and is not prepared by Accelerator:
+
+```text
+sampler.set_epoch(data_epoch)
+for batch in dataloader:
+ condition = cached prompt/input tensors
+ output = freshly decoded target or chosen/rejected media
+ trainer.optimize_batch(batch)
+data_epoch += 1 # only after clean exhaustion
+```
+
+One complete dataloader traversal is one offline epoch. Source weights must be `1`,
+`data.sampler_type` remains `auto`, and `gradient_accumulation_steps` is an explicit integer. The
+rank-local batch count must be divisible by gradient accumulation; the framework does not add
+batches to close a partial accumulation window or flush one at epoch end. The official
+`DistributedSampler` retains its standard `drop_last=False` behavior and may repeat global tail
+indices to equalize rank lengths. Those indices are part of the finite loader traversal.
+
+Progress uses three independent counters:
+
+| Counter | Advances when | Used by |
+|---|---|---|
+| `rollout_iteration` | One generation acquisition completes. | Online rollout cadence and its compatibility `epoch`. |
+| `data_epoch` | One finite offline dataloader traversal completes. | Offline epoch, sampler shuffle epoch, save/eval boundaries. |
+| `optimizer_step` | One optimizer update completes. | Training metrics and optimizer-step state. |
+
+Offline EMA updates on optimizer-step cadence because one data epoch may contain many optimizer
+updates. Online algorithms retain their rollout-cycle EMA cadence. Similarly,
+`num_train_timesteps` means independently sampled flow-matching terms averaged inside one offline
+microbatch; it neither advances `optimizer_step` nor multiplies gradient accumulation.
+
+An exact-state checkpoint (`log.save_model_only: false`) locks the realized training loader plus
+evaluation cadence, sampling arguments, ordered eval loaders, per-dataset overrides, and eval
+reward configuration. Online resume replays evaluation after its pre-rollout checkpoint, and
+evaluation adapters or rewards may consume global RNG before the next training acquisition. MPS
+cannot currently save exact state because Accelerate does not serialize MPS RNG; use
+`log.save_model_only: true` on Apple Silicon.
+
+The target codec is adapter-owned but role-neutral at its numerical core. Condition and output
+encoding reuse the same pixel preprocessing, VAE transform, normalization, and packing helpers.
+Their semantic policies remain explicit: official condition paths commonly use posterior
+`argmax`, while stochastic target training uses posterior `sample` with an optional generator.
+Sharing a transform must never silently erase that role boundary.
+
+For offline DPO, chosen and rejected arms share the primary timestep, component-time mapping, and
+diffusion noise. Both policy arms run before one frozen-reference scope covers both reference
+forwards. SFT has no reference branch.
+
## Stage 2: K-Repeat Sampling
+Stages 2–6 in this guide describe generation acquisition. Dataset acquisition replaces Stages 2–5
+with the finite-loader path above and enters its objective through `optimize_batch()`.
+
**Goal**: Construct batches where each unique prompt appears exactly $K$ times (`group_size`), enabling group-relative advantage computation.
### Input / Output
@@ -350,13 +446,13 @@ train:
## Stage 6: Policy Optimization
-**Goal**: Update the denoising model's parameters using the computed advantages and PPO-style clipped policy gradient.
+**Goal**: Update the denoising model through the selected online or offline objective.
### Input / Output
| | Description |
|---|---|
-| **Input** | `List[BaseSample]` with advantages, trajectories, and log-probs stored |
+| **Input** | Generated `List[BaseSample]` for generation acquisition, or one typed offline batch for dataset acquisition. |
| **Output** | Updated model parameters; logged loss metrics |
### How It Works (GRPO)
@@ -412,7 +508,9 @@ def optimize(self, samples):
| **AWM** | Samples fresh timesteps; weights velocity matching loss by advantage; PPO clipping + EMA-KL regularization |
| **DGPO** | Samples fresh timesteps via `TimeSampler`; applies group-level preference objective with optional PPO clipping and EMA-reference KL |
| **CRD** | Samples fresh timesteps; reward distillation against CFG-guided teacher with adaptive KL; old/sampling model snapshots and centered advantages |
-| **DPO** | Preference loss on chosen/rejected pairs; pairs formed at the start of `optimize` after advantages |
+| **DPO** | Online preference loss on reward-ranked pairs formed at the start of `optimize` after advantages |
+| **SFT** | On-the-fly target encoding followed by independently noised flow-matching loss in `optimize_batch` |
+| **Offline DPO** | On-the-fly chosen/rejected encoding, shared timestep/noise, and policy-vs-reference DPO loss in `optimize_batch` |
### Optimizer Configuration
@@ -559,7 +657,7 @@ unverified; use DDP or FSDP.
### Key Points
-- **Inner epochs**: Samples can be reused for multiple optimization passes (`num_inner_epochs`), amortizing the cost of sampling.
+- **Generation inner epochs**: Generated samples can be reused for multiple optimization passes (`num_inner_epochs`), amortizing rollout cost. Offline `max_epochs` instead counts full loader traversals.
- **Gradient accumulation**: The `accelerator.accumulate()` context handles gradient accumulation across timesteps and micro-batches, with optimizer steps only at sync boundaries.
- **KL regularization**: Optional penalty keeping the policy close to a reference model (or EMA model for AWM), preventing reward hacking.
- **Per-timestep iteration**: GRPO iterates over each stored trajectory timestep, computing loss at each. NFT, AWM, DGPO, and CRD sample fresh timesteps independently of the sampling trajectory.
@@ -593,7 +691,25 @@ Epoch N
└── Optimizer step at sync boundaries
```
-*DPO*: form chosen/rejected pairs at the **start** of `optimize()` (after advantages exist), then run the preference loss; there is no pair formation in `prepare_feedback()`.
+*Online DPO*: form chosen/rejected pairs at the **start** of `optimize()` (after advantages exist), then run the preference loss; there is no pair formation in `prepare_feedback()`.
+
+A complete offline epoch is a different acquisition shape:
+
+```text
+Data epoch N
+├── DistributedSampler.set_epoch(N)
+├── Exhaust every rank-local dataloader batch
+│ ├── Reuse cached prompt/input condition
+│ ├── Decode target or chosen/rejected media from source
+│ ├── Encode output state on the fly
+│ ├── Compute SFT or offline-DPO loss
+│ └── Optimizer step at explicit GAS sync boundaries
+└── Advance data_epoch only after clean exhaustion
+```
+
+There is no rollout, training reward, advantage computation, or online pair formation in this
+path. Save/evaluation boundaries use the completed data epoch; training metrics use the independent
+optimizer-step counter.
## Structured multimodal trajectories
@@ -609,4 +725,4 @@ The component order is adapter-owned, for example `("video", "audio")` in MiniMa
H3. Conditioning is packed and replayed by the adapter; trainers such as GRPO,
GRPO-Guard, DPPO, DiffusionNFT, AWM, DPO, DGPO, CRD, and DiffusionOPD consume the
same state interface. H3 accepts neutral guidance `1.0`; framework-interface compatibility
-does not itself establish real-weight numerical parity.
\ No newline at end of file
+does not itself establish real-weight numerical parity.
From 2ba3d7e4cc3ededf5fb12d2027c5ea063291ab0a Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 19:32:06 +0800
Subject: [PATCH 23/76] fix(examples): align MiniMax H3 T2VA recipe contract
---
.agents/knowledge/topics/fix_patterns.md | 8 ++++++++
examples/README.md | 13 +++++++------
guidance/datasets.md | 14 +++++++++-----
tests/examples/test_minimax_h3_examples.py | 19 +++++++++++++++++++
4 files changed, 43 insertions(+), 11 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 110ffcfdb..9b6f6737b 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -190,6 +190,14 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Lesson**: When delegating sharding to an official sampler, define epoch semantics over its realized finite loader. Distinguish sampler-level repeated indices from optimizer-level synthetic padding.
- **Related Constraint**: #9
+### Recipe migrations must move tests and documentation with the config
+- **Date**: 2026-08-28
+- **Symptom**: Rebasing onto the precision-aware loading branch changed the MiniMax H3 T2VA default to the shared `vid_prompt` source, added ImageBind routing, and removed its old unvalidated warning, while the executable example test and user guides still required the prior dataset and wording.
+- **Root Cause**: The recipe-only commit updated YAML semantics without treating example assertions, dataset links, dependency notes, and validation-status language as one public workflow contract.
+- **Fix**: The T2VA default test now locks the shared TXT manifests and CLAP/ImageBind routing while retaining the dedicated JSONL fixture check for the validated debug recipe. The example and dataset guides now describe the shared source, ImageBind dependency, and the exact evidence boundary without claiming a completed long run.
+- **Lesson**: An example configuration is executable documentation. Any recipe migration must update its production parse test, linked data provenance, optional dependency instructions, and validation claims in the same integration change.
+- **Related Constraint**: #15
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/examples/README.md b/examples/README.md
index 29e5d8e80..2dea62db8 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -89,9 +89,10 @@ The T2VA `debug.yaml` recipe is real-weight validated with the 61 GB checkpoint
1 GPU and 16 GPUs across two nodes completed CPS rollout, video/audio decode,
CLAP reward, GRPO replay/backward/optimizer step, and LoRA checkpoint save/resume.
Its 64x96 canvas is intentionally a correctness geometry. The quality-oriented T2VA
-default remains an unverified quality starting point. FL2VA and Ref2VA are
-**Schema/API validated only** rather than claims of training stability or reward
-improvement.
+default is now the shared-`vid_prompt`, LoRA-rank-64 baseline aligned with the LTX2
+T2AV recipe and uses both CLAP and ImageBind rewards. It is configuration/API
+validated; no completed long-run reward trend is claimed. FL2VA and Ref2VA are also
+**Schema/API validated only**, rather than claims of training stability or reward improvement.
The T2VA `quality_720p_fsdp2.yaml` recipe is the active native-quality path:
768x1344, 124 frames, 24 denoising steps, LoRA rank 64 / alpha 128, and two
@@ -99,9 +100,9 @@ updates from 48 prompt groups per epoch. Its real-weight FSDP2 initialization,
checkpoint, native-resolution decode, and CLAP evaluation are validated; a
completed long-run reward trend is not yet claimed.
-FL2VA and Ref2VA use Meta ImageBind for audio-video alignment. Install ImageBind
-and PyTorchVideo from their upstream repositories before running those examples;
-ImageBind is licensed CC-BY-NC-SA 4.0 (NonCommercial).
+The aligned T2VA default, FL2VA, and Ref2VA use Meta ImageBind for audio-video
+alignment. Install ImageBind and PyTorchVideo from their upstream repositories before
+running those examples; ImageBind is licensed CC-BY-NC-SA 4.0 (NonCommercial).
```bash
pip install git+https://github.com/facebookresearch/ImageBind.git
diff --git a/guidance/datasets.md b/guidance/datasets.md
index 8edd8aac9..c402f34d7 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -321,8 +321,10 @@ T2VA `debug.yaml` is real-weight validated on 1 and 16 GPUs, including LoRA
checkpoint save/resume. Its 64x96 canvas validates
correctness and memory fit, not visual quality or reward improvement.
`quality_720p_fsdp2.yaml` has real-weight initialization, checkpoint, native-resolution
-decode, and evaluation coverage; no long-run reward trend is claimed. The default,
-FL2VA, and Ref2VA configs remain schema/API-validated starting points.
+decode, and evaluation coverage; no long-run reward trend is claimed. The aligned default uses
+the shared `dataset/vid_prompt` source, LoRA rank 64, and CLAP plus ImageBind rewards; it remains a
+configuration/API-validated baseline without a published long-run trend. FL2VA and Ref2VA remain
+schema/API-validated starting points.
### T2VA: `minimax-h3-t2va`
@@ -332,9 +334,11 @@ T2VA is prompt-only:
{"prompt":"A small paper windmill turns beside a quiet stream with synchronized birdsong."}
```
-Do not include negative prompts, images, or references. Use the
-[T2VA dataset fixture](../dataset/minimax_h3_t2va/train.jsonl) with the
-[T2VA GRPO configuration](../examples/grpo/lora/minimax_h3_t2va/default.yaml).
+Do not include negative prompts, images, or references. The
+[T2VA default GRPO configuration](../examples/grpo/lora/minimax_h3_t2va/default.yaml) uses the
+shared [`vid_prompt` TXT dataset](../dataset/vid_prompt/train.txt). The dedicated
+[T2VA JSONL fixture](../dataset/minimax_h3_t2va/train.jsonl) remains the compact input for the
+real-weight validated `debug.yaml` recipe.
### FL2VA: `minimax-h3-fl2va`
diff --git a/tests/examples/test_minimax_h3_examples.py b/tests/examples/test_minimax_h3_examples.py
index 345786ec1..e56f19d56 100644
--- a/tests/examples/test_minimax_h3_examples.py
+++ b/tests/examples/test_minimax_h3_examples.py
@@ -89,6 +89,15 @@ def test_examples_parse_through_production_config_and_registry(
assert "N + 1 states and exactly N trainable transitions" in yaml_text
assert "B=1" in yaml_text
assert "no CFG" in yaml_text
+ if directory == "minimax_h3_t2va":
+ assert "not been run with the 61 GB checkpoint" not in yaml_text
+ assert [reward.reward_model for reward in config.reward_args] == [
+ "clap",
+ "imagebind",
+ ]
+ assert all(reward.applicable_datasets == ["vid_prompt"] for reward in config.reward_args)
+ else:
+ assert "not been run with the 61 GB checkpoint" in yaml_text
assert "stg_scale" not in yaml_text
assert "modality_scale" not in yaml_text
assert "negative_prompt" not in yaml_text
@@ -120,12 +129,22 @@ def test_t2va_validated_variants_parse(
def test_t2va_manifests_contain_prompt_only() -> None:
+ """Keep the dedicated JSONL fixture used by the validated debug recipe strict."""
for split in ("train", "test"):
rows = _read_jsonl(ROOT / "dataset/minimax_h3_t2va" / f"{split}.jsonl")
assert rows
assert all(set(row) == {"prompt"} and row["prompt"] for row in rows)
+def test_t2va_default_shared_text_manifests_contain_prompts() -> None:
+ """The aligned default recipe uses the shared prompt-only TXT dataset."""
+ for split in ("train", "test"):
+ prompts = (ROOT / "dataset/vid_prompt" / f"{split}.txt").read_text(encoding="utf-8")
+ lines = prompts.splitlines()
+ assert lines
+ assert all(prompt.strip() for prompt in lines)
+
+
def test_fl2va_manifests_preserve_one_or_two_ordered_images() -> None:
dataset_dir = ROOT / "dataset/minimax_h3_fl2va"
for split in ("train", "test"):
From 142760e4e51504d2da0c435dca035571c3b08ba1 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 22:18:06 +0800
Subject: [PATCH 24/76] feat(minimax-h3): add offline AV target encoding
---
.../knowledge/topics/adapter_conventions.md | 2 +
.agents/knowledge/topics/minimax_h3.md | 68 ++
README.md | 6 +-
guidance/datasets.md | 23 +-
.../data_utils/offline_dataset.py | 50 +
.../data_utils/offline_train_data.py | 8 +-
src/flow_factory/models/minimax_h3/_output.py | 891 ++++++++++++++++++
.../models/minimax_h3/adapters.py | 67 +-
.../models/minimax_h3/workflow.py | 68 +-
src/flow_factory/models/pipeline_contracts.py | 58 +-
tests/data_utils/test_offline_dataset.py | 48 +-
tests/data_utils/test_offline_train_data.py | 43 +
tests/models/minimax_h3/test_output_codec.py | 604 ++++++++++++
tests/models/minimax_h3/test_review_fixes.py | 2 +
.../test_offline_output_capability_matrix.py | 10 +-
.../test_pipeline_contract_constructors.py | 92 ++
16 files changed, 2003 insertions(+), 37 deletions(-)
create mode 100644 src/flow_factory/models/minimax_h3/_output.py
create mode 100644 tests/models/minimax_h3/test_output_codec.py
create mode 100644 tests/models/test_pipeline_contract_constructors.py
diff --git a/.agents/knowledge/topics/adapter_conventions.md b/.agents/knowledge/topics/adapter_conventions.md
index e0c465dec..8f2ce5dd0 100644
--- a/.agents/knowledge/topics/adapter_conventions.md
+++ b/.agents/knowledge/topics/adapter_conventions.md
@@ -48,6 +48,8 @@ the complete mapping when its forward has different semantics or additional guid
- SenseNova neutralizes text and image guidance together and disables CFG normalization.
- Bagel replaces the base mapping with its actual `cfg_text_scale` / `cfg_img_scale` arguments;
it must not inherit an irrelevant `guidance_scale` key through its permissive `**kwargs`.
+- MiniMax H3 T2VA inherits neutral `guidance_scale=1.0`; its strict forward validates that
+ interface value even though the guidance-distilled checkpoint has no CFG branch.
Wan I2V and LTX2 remain behind explicit offline output-codec blockers. Wan I2V must mirror the two
neutral Wan transformer scales before it is enabled. LTX2 must set video/audio CFG scales and
diff --git a/.agents/knowledge/topics/minimax_h3.md b/.agents/knowledge/topics/minimax_h3.md
index a20ab1097..fd41f1962 100644
--- a/.agents/knowledge/topics/minimax_h3.md
+++ b/.agents/knowledge/topics/minimax_h3.md
@@ -53,6 +53,73 @@ and lets that boundary stay strict.
`samples/references.py`.
- PyAV >=18.0.0 decodes video/audio references, including embedded or separate soundtracks.
+## Offline T2VA output contract
+
+`minimax-h3-t2va` supports SFT and offline DPO with one exact ordered output pair:
+video first, then audio. Both `fps` and `sample_rate` are required in V2 supervision.
+Targets are decoded on demand; neither pixels, waveforms, nor VAE latents enter the condition
+cache. The pipeline's single-sample capability also forces condition-cache preprocessing to B=1,
+independently of the global preprocessing batch-size setting.
+
+The codec cross-validates cached T2VA layout and geometry against the current training config. It
+resamples video onto the configured fixed 24-fps grid and canvas, truncates audio on its declared
+source clock before a single conversion to the audio-VAE rate, and aligns stereo audio to the exact
+latent duration, samples and normalizes the video posterior, takes and normalizes the official
+audio posterior mode, then packs structured rows in `("video", "audio")` order. The codec does
+not duplicate input-owned fields in output forward context. Replay nests the flat cached layout
+and derives empty T2VA condition prefixes from the current state, preserving storage dtype and
+device. Exact velocity-only offline forwards return before either component scheduler steps, so
+SFT and offline DPO do not sample unused transitions or perturb scheduler RNG cadence. Every
+encoded row count must match the cached layout before transformer execution.
+
+FL2VA and Ref2VA remain online-only. Their output AV encoding can reuse the T2VA numerical
+codec, but their cached media conditions still need a separately owned, reproducible
+condition-prefix binder. In particular, both offline-DPO arms must consume the same conditioned
+prefix noise; do not generate those prefixes independently inside the chosen/rejected codecs.
+
+## Fix records
+
+### Offline targets preserve configured geometry and logical source clocks
+
+- **Date**: 2026-08-28
+- **Symptom**: An internally valid stale H3 condition cache could select another output canvas or
+ frame count, while an audio `sample_rate` override caused decoder resampling followed by a
+ second codec resample.
+- **Root Cause**: The output codec trusted cached geometry without comparing the current training
+ config, and the generic audio decoder treated source-rate metadata as a target decode rate.
+- **Fix**: The codec now cross-validates cached H/W and the officially aligned frame count against
+ current training arguments. Audio decoding preserves file samples; the codec truncates on the
+ declared source clock before exactly one model-rate conversion.
+- **Lesson**: Cached geometry must be checked against its configured authority, and media rate
+ overrides describe logical source clocks rather than preprocessing requests.
+- **Related Constraint**: #8, #26
+
+### Velocity-only H3 forwards bypass both schedulers
+
+- **Date**: 2026-08-28
+- **Symptom**: SFT and offline DPO requested only velocity but still sampled unused video/audio
+ scheduler transitions, wasting memory and changing RNG cadence.
+- **Root Cause**: The adapter boundary did not route the exact velocity-only request to the
+ existing scheduler-free `forward_h3_state` path.
+- **Fix**: The adapter detects a non-log-probability `("velocity",)` request with no replay next
+ state, returns `MultiModalStepOutput(velocity=...)`, and never enters either scheduler.
+- **Lesson**: Decoupled velocity objectives must stop at model prediction; requesting fewer return
+ fields is not sufficient if the adapter still executes transition side effects.
+- **Related Constraint**: #7
+
+### Single-sample capability governs condition preprocessing
+
+- **Date**: 2026-08-28
+- **Symptom**: A multi-row H3 offline manifest could reach its B=1 preprocessor with the global
+ condition-cache batch size, even though the training loader correctly rejected B>1.
+- **Root Cause**: The offline cache builder forced row-wise preprocessing only for ordered
+ references and did not apply the adapter's general batching capability.
+- **Fix**: The cache builder now derives its effective preprocessing batch size from both ordered
+ reference binding and `BatchCapability.SINGLE_SAMPLE`.
+- **Lesson**: One adapter-owned batching contract must govern preprocessing and model execution;
+ otherwise framework stages can disagree before training starts.
+- **Related Constraint**: #8, #12
+
## Verification boundary
All workflows have pinned API/schema/no-weight verification. T2VA additionally completed
@@ -68,6 +135,7 @@ improvement, convergence, or numerical parity.
- [ ] Rerun the real public-symbol and no-weight component-spec/workflow probes.
- [ ] Run H3 scheduler/runtime/registry/reference tests in the pinned environment.
- [ ] Parse all H3 examples through `Arguments.load_from_yaml`.
+- [ ] Run the T2VA output-codec and common SFT/offline-DPO structured-state tests.
- [ ] Rerun the documented T2VA real-weight smoke before changing support or memory claims.
## Cross-refs
diff --git a/README.md b/README.md
index 8e0b9ce45..0b9954858 100644
--- a/README.md
+++ b/README.md
@@ -99,8 +99,10 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
> **Offline output support:** SFT and offline DPO currently support `sd3-5`, `flux1`,
> `flux1-kontext`, `flux2`, `flux2-klein`, `qwen-image`, `qwen-image-edit-plus`, `z-image`,
-> `bagel`, `sensenova`, and `wan2_t2v`. Wan I2V, LTX2, and MiniMax H3 fail fast on their
-> currently unresolved output/condition or audio-video semantics. See the
+> `bagel`, `sensenova`, `wan2_t2v`, and `minimax-h3-t2va`. Wan I2V, LTX2, and the
+> conditioned MiniMax H3 FL2VA/Ref2VA workflows fail fast on their currently unresolved
+> output/condition semantics. MiniMax H3 T2VA targets use an exact ordered video/audio pair,
+> encoded on demand into its structured latent state. See the
> [offline model matrix](guidance/datasets.md#offline-model-support).
> **MiniMax H3 status:** the T2VA debug and
diff --git a/guidance/datasets.md b/guidance/datasets.md
index c402f34d7..ff0d74726 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -108,9 +108,11 @@ must carry the supervision type required by its trainer. Prompt-only rows, mixed
preference rows, unknown keys, and non-V2 records fail during manifest loading.
All V2 media paths are resolved against that source's `dataset_dir`; an absolute path is retained.
-Images and videos have built-in CPU decoders. Video targets require PyAV 18 or newer. There is no
-default audio target decoder yet, which is one reason the current audio-video adapters are blocked
-for offline objectives.
+Images, videos, and audio have built-in CPU decoders. Video targets require PyAV 18 or newer.
+Decoded audio is a detached CPU `float32` waveform shaped `(channels, samples)`. A manifest
+`sample_rate` is a logical source-clock override and does not pre-resample the decoded samples;
+source-clock truncation, channel conversion, the single model-rate conversion, posterior selection,
+and latent packing remain adapter-owned.
Tiny schema-complete fixtures and configs are available for
[SFT](../examples/sft/lora/sd3_5/default.yaml) and
@@ -201,9 +203,20 @@ output semantics.
|---|---|---|
| Supported | `sd3-5`, `flux1`, `flux1-kontext`, `flux2`, `flux2-klein`, `qwen-image`, `qwen-image-edit-plus`, `z-image`, `bagel`, `sensenova` | Image-output codecs with adapter-specific geometry and packing. SenseNova uses the existing grouped `images` input with within-type order, not heterogeneous references. |
| Supported | `wan2_t2v` | Video targets require `fps`; the codec resamples to configured frames/rate and samples the Wan VAE posterior on the fly. |
+| Supported | `minimax-h3-t2va` | Every candidate is an exact ordered `(video, audio)` pair with required `fps` and `sample_rate`. The codec aligns both streams to configured H3 geometry, samples the video posterior, takes the official audio-posterior mode, and packs structured video/audio rows on the fly. Condition preprocessing and training remain B=1. |
| Blocked | `wan2_i2v` | Output geometry depends on the first-frame VAE latent/mask, while the current condition cache does not preserve the source pixels needed by that binder. |
-| Blocked | `ltx2_t2av`, `ltx2_i2av` | Lossless audio decode/rate metadata and exact audio-video duration alignment are not unified; I2AV also needs the pinned first-frame active mask. |
-| Blocked | `minimax-h3-t2va`, `minimax-h3-fl2va`, `minimax-h3-ref2va` | The audio-video boundary and official target-video posterior policy are not yet defined for offline targets. |
+| Blocked | `ltx2_t2av`, `ltx2_i2av` | Their adapter codec still needs exact LTX-specific audio/video duration alignment, latent packing, and decode context; I2AV also needs the pinned first-frame active mask. |
+| Blocked | `minimax-h3-fl2va`, `minimax-h3-ref2va` | Their cached input media still need a shared, reproducible offline condition-prefix binder; offline DPO must reuse the same conditioned prefix noise for both preference arms. |
+
+MiniMax H3 T2VA supervision lists video first and audio second. The target video must cover the
+configured 24-fps duration; it is deterministically sampled onto that frame grid and resized to the
+configured canvas. Audio is converted to stereo at the H3 audio-VAE rate, then trimmed or
+right-padded to the exact aligned latent duration. For example:
+
+```jsonl
+{"schema_version":2,"input":{"prompt":"Ocean waves beneath an aurora.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"video","path":"targets/aurora.mp4","fps":24.0},{"type":"audio","path":"targets/aurora.wav","sample_rate":32000}]}},"metadata":{}}
+{"schema_version":2,"input":{"prompt":"Ocean waves beneath an aurora.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"video","path":"pairs/chosen.mp4","fps":24.0},{"type":"audio","path":"pairs/chosen.wav","sample_rate":32000}]},"rejected":{"media":[{"type":"video","path":"pairs/rejected.mp4","fps":24.0},{"type":"audio","path":"pairs/rejected.wav","sample_rate":32000}]}},"metadata":{}}
+```
## Common task formats
diff --git a/src/flow_factory/data_utils/offline_dataset.py b/src/flow_factory/data_utils/offline_dataset.py
index 0822179bf..9043a15bc 100644
--- a/src/flow_factory/data_utils/offline_dataset.py
+++ b/src/flow_factory/data_utils/offline_dataset.py
@@ -38,6 +38,8 @@
from pydantic import ValidationError
from torch.utils.data import Dataset
+from ..utils.audio import load_audio
+
try:
import av
except ImportError:
@@ -511,10 +513,57 @@ def decode_video(asset: MediaAsset) -> np.ndarray:
return np.ascontiguousarray(video, dtype=np.uint8)
+def decode_audio(asset: MediaAsset) -> torch.Tensor:
+ """Decode one target audio asset as a detached CPU waveform.
+
+ The returned contiguous ``float32`` tensor has shape ``(channels, samples)``.
+ The manifest ``sample_rate`` is a logical source-clock override, so decoding
+ intentionally preserves the file's samples instead of resampling them. The
+ :class:`DecodedMedia` boundary carries that override to the model codec, which
+ owns source-clock truncation, channel conversion, and the single model-rate
+ resample. This mirrors the source-clock semantics of video ``fps`` overrides.
+ Keeping this function at module scope makes the default decoder safe to pickle
+ under spawn-based DataLoader workers.
+
+ Args:
+ asset: Normalized audio reference with a resolved local path and optional rate override.
+
+ Returns:
+ Detached contiguous CPU waveform shaped ``(channels, samples)``.
+
+ Raises:
+ TypeError: If the audio backend returns a non-tensor or non-floating payload.
+ ValueError: If the decoded waveform is empty, non-finite, or not two-dimensional.
+ """
+ waveform = load_audio(asset.path, sample_rate=None)
+ if not isinstance(waveform, torch.Tensor):
+ raise TypeError(
+ f"failed to decode target audio {asset.path!r}: expected torch.Tensor, "
+ f"received {type(waveform).__name__}"
+ )
+ if not waveform.is_floating_point():
+ raise TypeError(
+ f"failed to decode target audio {asset.path!r}: expected floating waveform, "
+ f"received {waveform.dtype}"
+ )
+ waveform = waveform.detach().to(device="cpu", dtype=torch.float32).contiguous()
+ if waveform.ndim != 2 or waveform.shape[0] < 1 or waveform.shape[1] < 1:
+ raise ValueError(
+ f"failed to decode target audio {asset.path!r}: expected non-empty waveform "
+ f"shaped (channels,samples), received {tuple(waveform.shape)}"
+ )
+ if not torch.isfinite(waveform).all():
+ raise ValueError(
+ f"failed to decode target audio {asset.path!r}: waveform contains non-finite values"
+ )
+ return waveform
+
+
DEFAULT_MEDIA_DECODERS: Mapping[MediaType, MediaDecoder] = MappingProxyType(
{
"image": decode_image,
"video": decode_video,
+ "audio": decode_audio,
}
)
@@ -840,6 +889,7 @@ def _require_record_supervision(
"PreferenceOutputBatch",
"compute_offline_condition_id",
"compute_offline_record_id",
+ "decode_audio",
"decode_image",
"decode_video",
"load_offline_manifest",
diff --git a/src/flow_factory/data_utils/offline_train_data.py b/src/flow_factory/data_utils/offline_train_data.py
index 83f6d9493..4fc16575a 100644
--- a/src/flow_factory/data_utils/offline_train_data.py
+++ b/src/flow_factory/data_utils/offline_train_data.py
@@ -223,6 +223,7 @@ def build_offline_train_dataloader(
preprocess_func=preprocess_func,
preprocess_kwargs=normalized_preprocess_kwargs,
preprocessing_batch_size=data_args.preprocessing_batch_size,
+ batch_capability=pipeline_io_contract.batch_capability,
force_reprocess=data_args.force_reprocess,
extra_hash_strs=[*normalized_extra_hash_strs, f"offline_train_source:{source.name}"],
preprocess_parallelism=data_args.preprocess_parallelism,
@@ -314,6 +315,7 @@ def _build_distributed_condition_cache(
preprocess_func: PreprocessCallable,
preprocess_kwargs: Mapping[str, Any],
preprocessing_batch_size: int,
+ batch_capability: BatchCapability,
force_reprocess: bool,
extra_hash_strs: Sequence[str],
preprocess_parallelism: Literal["global", "local"],
@@ -322,7 +324,11 @@ def _build_distributed_condition_cache(
) -> HFDataset:
"""Build one input-only cache through the existing rank-safe orchestrator."""
ordered_references = _supports_ordered_references(preprocess_func)
- effective_batch_size = 1 if ordered_references else preprocessing_batch_size
+ effective_batch_size = (
+ 1
+ if ordered_references or batch_capability is BatchCapability.SINGLE_SAMPLE
+ else preprocessing_batch_size
+ )
raw_dataset = project_offline_condition_dataset(
records,
source_name=source_name,
diff --git a/src/flow_factory/models/minimax_h3/_output.py b/src/flow_factory/models/minimax_h3/_output.py
new file mode 100644
index 000000000..dff208c3f
--- /dev/null
+++ b/src/flow_factory/models/minimax_h3/_output.py
@@ -0,0 +1,891 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""On-the-fly audiovisual target encoding for MiniMax H3 T2VA."""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping
+from dataclasses import dataclass
+from numbers import Real
+from typing import Any, ClassVar, Dict, Optional, Tuple
+
+import numpy as np
+import torch
+from PIL import Image
+
+from ...contracts import MediaType
+from ...samples import LatentState
+from ...utils.audio import convert_audio
+from ..configured_image_output import retrieve_vae_latents
+from ..output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+from ._common import pack_audio_latents, pack_video_latents, validate_target_state
+from .workflow import _normalize_geometry, _normalize_layout
+
+_GEOMETRY_FIELDS = (
+ "height",
+ "width",
+ "num_frames",
+ "num_latent_frames",
+ "latent_height",
+ "latent_width",
+ "num_audio_latents",
+)
+_LAYOUT_FIELDS = (
+ "position_ids",
+ "token_tags",
+ "video_indices",
+ "audio_indices",
+ "text_indices",
+ "num_condition_video_rows",
+ "num_condition_audio_rows",
+)
+_RELEASED_PATCH_SIZE = (1, 2, 2)
+_RELEASED_VIDEO_LATENT_CHANNELS = 24
+_RELEASED_AUDIO_CHANNELS = 2
+_RELEASED_AUDIO_LATENT_CHANNELS = 32
+_RELEASED_AUDIO_SAMPLE_RATE = 32000
+_RELEASED_AUDIO_HOP_LENGTH = 800
+
+
+@dataclass(frozen=True, slots=True)
+class _H3ModelShape:
+ patch_size: Tuple[int, int, int]
+ video_latent_channels: int
+ audio_channels: int
+ audio_latent_channels: int
+
+
+@dataclass(frozen=True, slots=True)
+class MiniMaxH3AVOutputCodec:
+ """Encode one configured T2VA video/audio target into packed H3 rows."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("vae", "audio_vae")
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Encode the exact ``(video, audio)`` target sequence for one sample.
+
+ Diffusers defines H3's conditioning encoder, but not an offline target
+ encoder. Video posterior sampling is therefore a framework policy inferred
+ from latent-diffusion training objectives. In particular, the condition-only
+ float16 rounding step is intentionally not applied to clean targets. Audio
+ targets use the posterior mode because the official H3 pipeline never samples
+ the audio posterior. Both components are normalized and packed exactly like
+ generated target rows.
+
+ Args:
+ media_batch: One decoded video/audio pair.
+ condition: Cached T2VA condition and its authoritative packed layout.
+ generator: Optional generator forwarded only to video posterior sampling.
+
+ Returns:
+ Detached clean video/audio rows plus the output-owned decode context.
+ """
+ if len(media_batch) != 1:
+ raise ValueError(
+ f"MiniMax H3 T2VA output encoding requires B=1, received B={len(media_batch)}"
+ )
+ candidate = media_batch[0]
+ if len(candidate) != 2:
+ raise ValueError(
+ "MiniMax H3 T2VA output codec expected exact (video, audio) media, "
+ f"received {len(candidate)} items"
+ )
+ video_media, audio_media = candidate
+ geometry = resolve_h3_output_geometry(self.adapter, condition)
+ model_shape = _resolve_h3_model_shape(self.adapter)
+
+ video_pixels = prepare_h3_target_video(
+ video_media.payload,
+ source_fps=video_media.fps,
+ target_frames=geometry["num_frames"],
+ target_fps=float(self.adapter.pipeline.fps),
+ height=geometry["height"],
+ width=geometry["width"],
+ )
+ video_latents = encode_h3_target_video(
+ self.adapter,
+ video_pixels,
+ generator=generator,
+ )
+ expected_video_shape = (
+ 1,
+ model_shape.video_latent_channels,
+ geometry["num_latent_frames"],
+ geometry["latent_height"],
+ geometry["latent_width"],
+ )
+ if tuple(video_latents.shape) != expected_video_shape:
+ raise ValueError(
+ "MiniMax H3 target video latent geometry mismatch: "
+ f"expected {expected_video_shape}, received {tuple(video_latents.shape)}"
+ )
+
+ audio_vae = self.adapter.get_component("audio_vae")
+ sample_rate = _positive_int(
+ getattr(audio_vae.config, "sampling_rate", None),
+ "audio_vae.config.sampling_rate",
+ )
+ hop_length = _positive_int(
+ getattr(audio_vae, "hop_length", None),
+ "audio_vae.hop_length",
+ )
+ target_audio_samples = geometry["num_audio_latents"] * hop_length
+ waveform = prepare_h3_target_audio(
+ audio_media.payload,
+ source_sample_rate=audio_media.sample_rate,
+ target_sample_rate=sample_rate,
+ target_samples=target_audio_samples,
+ target_duration_seconds=geometry["num_frames"] / float(self.adapter.pipeline.fps),
+ )
+ audio_latents = encode_h3_target_audio(self.adapter, waveform)
+ expected_audio_shape = (
+ model_shape.audio_channels,
+ model_shape.audio_latent_channels,
+ geometry["num_audio_latents"],
+ )
+ if tuple(audio_latents.shape) != expected_audio_shape:
+ raise ValueError(
+ "MiniMax H3 target audio latent geometry mismatch: "
+ f"expected {expected_audio_shape}, received {tuple(audio_latents.shape)}"
+ )
+
+ clean_state = LatentState(
+ {
+ "video": pack_video_latents(video_latents.to(torch.float32)),
+ "audio": pack_audio_latents(audio_latents.to(torch.float32).unsqueeze(0)),
+ }
+ )
+ validate_target_state(clean_state)
+ _validate_h3_t2va_input_layout(condition, clean_state)
+ frame_rate = float(self.adapter.pipeline.fps)
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=geometry["height"],
+ width=geometry["width"],
+ frames=geometry["num_frames"],
+ fps=frame_rate,
+ ),
+ MediaGeometrySignature(
+ type=MediaType.AUDIO,
+ samples=target_audio_samples,
+ sample_rate=sample_rate,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=clean_state,
+ forward_context={},
+ decode_context={"geometry": geometry},
+ geometry_signatures=(signature,),
+ )
+
+
+def resolve_h3_output_geometry(adapter: Any, condition: Mapping[str, Any]) -> Dict[str, int]:
+ """Resolve and validate configured geometry from the cached T2VA condition.
+
+ Args:
+ adapter: Active MiniMax H3 adapter with configured pipeline components.
+ condition: Cached input condition carrying flat H3 geometry fields.
+
+ Returns:
+ Canonical positive integer geometry validated against the training config.
+ """
+ geometry = _normalize_geometry(condition)
+ missing = tuple(field for field in _GEOMETRY_FIELDS if field not in geometry)
+ if missing:
+ raise ValueError(f"MiniMax H3 offline condition geometry missing fields={missing}")
+ for field in _GEOMETRY_FIELDS:
+ _positive_int(geometry[field], f"condition.{field}")
+
+ pipeline = adapter.pipeline
+ model_shape = _resolve_h3_model_shape(adapter)
+ frame_rate = _positive_real(getattr(pipeline, "fps", None), "pipeline.fps")
+ if frame_rate != 24.0:
+ raise ValueError(
+ f"MiniMax H3 target encoding requires fixed 24 fps, received {frame_rate!r}"
+ )
+ configured_frame_rate = _positive_real(
+ getattr(adapter.training_args, "frame_rate", None),
+ "training_args.frame_rate",
+ )
+ if configured_frame_rate != frame_rate:
+ raise ValueError(
+ "MiniMax H3 configured frame rate must match the model clock: "
+ f"expected {frame_rate}, received {configured_frame_rate}"
+ )
+ configured_height = _positive_int(
+ getattr(adapter.training_args, "height", None),
+ "training_args.height",
+ )
+ configured_width = _positive_int(
+ getattr(adapter.training_args, "width", None),
+ "training_args.width",
+ )
+ if (geometry["height"], geometry["width"]) != (
+ configured_height,
+ configured_width,
+ ):
+ raise ValueError(
+ "MiniMax H3 cached output canvas does not match the current configured geometry: "
+ f"expected {(configured_height, configured_width)}, received "
+ f"{(geometry['height'], geometry['width'])}"
+ )
+
+ frames_per_chunk = _positive_int(
+ getattr(pipeline, "vae_frames_per_chunk", None),
+ "pipeline.vae_frames_per_chunk",
+ )
+ latents_per_chunk = _positive_int(
+ getattr(pipeline, "vae_latents_per_chunk", None),
+ "pipeline.vae_latents_per_chunk",
+ )
+ if latents_per_chunk >= frames_per_chunk:
+ raise ValueError(
+ "MiniMax H3 video VAE chunk geometry requires latents_per_chunk < "
+ f"frames_per_chunk, received {(latents_per_chunk, frames_per_chunk)}"
+ )
+ configured_num_frames = _positive_int(
+ getattr(adapter.training_args, "num_frames", None),
+ "training_args.num_frames",
+ )
+ aligned_configured_num_frames = configured_num_frames + (
+ (latents_per_chunk - configured_num_frames) % frames_per_chunk
+ )
+ if geometry["num_frames"] != aligned_configured_num_frames:
+ raise ValueError(
+ "MiniMax H3 cached output frame count does not match the current configured "
+ "frame count after official VAE alignment: "
+ f"expected {aligned_configured_num_frames} from configured "
+ f"num_frames={configured_num_frames}, received {geometry['num_frames']}"
+ )
+
+ min_duration = _positive_real(
+ getattr(pipeline, "min_duration", None),
+ "pipeline.min_duration",
+ )
+ max_duration = _positive_real(
+ getattr(pipeline, "max_duration", None),
+ "pipeline.max_duration",
+ )
+ if min_duration > max_duration:
+ raise ValueError(
+ "MiniMax H3 pipeline duration bounds are inverted: "
+ f"min_duration={min_duration}, max_duration={max_duration}"
+ )
+ duration = geometry["num_frames"] / frame_rate
+ if not min_duration <= duration <= max_duration:
+ raise ValueError(
+ "MiniMax H3 configured output duration is outside the pipeline contract: "
+ f"expected [{min_duration}, {max_duration}] seconds, received {duration} "
+ f"from {geometry['num_frames']} frames at {frame_rate} fps"
+ )
+
+ spatial_ratio = _positive_int(
+ getattr(pipeline, "vae_spatial_compression_ratio", None),
+ "pipeline.vae_spatial_compression_ratio",
+ )
+ expected_latent_height = geometry["height"] // spatial_ratio
+ expected_latent_width = geometry["width"] // spatial_ratio
+ if geometry["height"] % spatial_ratio or geometry["width"] % spatial_ratio:
+ raise ValueError(
+ "MiniMax H3 output height/width must be divisible by the video VAE spatial ratio "
+ f"{spatial_ratio}, received {(geometry['height'], geometry['width'])}"
+ )
+ if (geometry["latent_height"], geometry["latent_width"]) != (
+ expected_latent_height,
+ expected_latent_width,
+ ):
+ raise ValueError(
+ "MiniMax H3 cached spatial latent geometry mismatch: expected "
+ f"{(expected_latent_height, expected_latent_width)}, received "
+ f"{(geometry['latent_height'], geometry['latent_width'])}"
+ )
+ _, patch_height, patch_width = model_shape.patch_size
+ if geometry["latent_height"] % patch_height or geometry["latent_width"] % patch_width:
+ raise ValueError(
+ "MiniMax H3 cached latent height/width must be divisible by transformer patch "
+ f"{model_shape.patch_size}, received "
+ f"{(geometry['latent_height'], geometry['latent_width'])}"
+ )
+
+ num_frames = geometry["num_frames"]
+ if num_frames % frames_per_chunk != latents_per_chunk:
+ raise ValueError(
+ "MiniMax H3 target num_frames must satisfy the video VAE chunk geometry "
+ f"F % {frames_per_chunk} == {latents_per_chunk}, received {num_frames}"
+ )
+ expected_video_latents = (
+ num_frames - latents_per_chunk
+ ) // frames_per_chunk * latents_per_chunk + 2
+ if geometry["num_latent_frames"] != expected_video_latents:
+ raise ValueError(
+ "MiniMax H3 cached temporal latent geometry mismatch: expected "
+ f"{expected_video_latents}, received {geometry['num_latent_frames']}"
+ )
+ patch_time = model_shape.patch_size[0]
+ if geometry["num_latent_frames"] % patch_time:
+ raise ValueError(
+ "MiniMax H3 cached latent frame count must be divisible by transformer temporal "
+ f"patch {patch_time}, received {geometry['num_latent_frames']}"
+ )
+
+ audio_vae = adapter.get_component("audio_vae")
+ sample_rate = _positive_int(
+ getattr(audio_vae.config, "sampling_rate", None),
+ "audio_vae.config.sampling_rate",
+ )
+ hop_length = _positive_int(
+ getattr(audio_vae, "hop_length", None),
+ "audio_vae.hop_length",
+ )
+ pipeline_sample_rate = _positive_int(
+ getattr(pipeline, "audio_sampling_rate", None),
+ "pipeline.audio_sampling_rate",
+ )
+ if (
+ sample_rate,
+ pipeline_sample_rate,
+ hop_length,
+ ) != (
+ _RELEASED_AUDIO_SAMPLE_RATE,
+ _RELEASED_AUDIO_SAMPLE_RATE,
+ _RELEASED_AUDIO_HOP_LENGTH,
+ ):
+ raise ValueError(
+ "MiniMax H3 output codec requires the released audio clock "
+ f"sample_rate/hop_length={(_RELEASED_AUDIO_SAMPLE_RATE, _RELEASED_AUDIO_HOP_LENGTH)}, "
+ f"received config/pipeline/hop={(sample_rate, pipeline_sample_rate, hop_length)}"
+ )
+ expected_audio_latents = int(round(num_frames / frame_rate * sample_rate / hop_length))
+ if geometry["num_audio_latents"] != expected_audio_latents:
+ raise ValueError(
+ "MiniMax H3 cached audio latent geometry mismatch: expected "
+ f"{expected_audio_latents}, received {geometry['num_audio_latents']}"
+ )
+ return geometry
+
+
+def prepare_h3_target_video(
+ payload: Any,
+ *,
+ source_fps: Any,
+ target_frames: int,
+ target_fps: float,
+ height: int,
+ width: int,
+) -> torch.Tensor:
+ """Resample decoded RGB frames onto the configured H3 video grid.
+
+ Args:
+ payload: Decoded uint8 RGB frames shaped ``(F, H, W, 3)``.
+ source_fps: Logical source frame rate from the media manifest.
+ target_frames: Exact aligned output frame count.
+ target_fps: Fixed model frame rate.
+ height: Configured output height.
+ width: Configured output width.
+
+ Returns:
+ Float32 pixels shaped ``(1, 3, F, H, W)`` in the unit interval.
+ """
+ if not isinstance(payload, np.ndarray):
+ raise TypeError(
+ "MiniMax H3 target video expected a decoded NumPy array, "
+ f"received {type(payload).__name__}"
+ )
+ if payload.dtype != np.uint8 or payload.ndim != 4 or payload.shape[-1] != 3:
+ raise ValueError(
+ "MiniMax H3 target video must be uint8 RGB shaped (F,H,W,3), "
+ f"received dtype={payload.dtype}, shape={tuple(payload.shape)}"
+ )
+ if payload.shape[0] < 1:
+ raise ValueError("MiniMax H3 target video must contain at least one frame")
+ source_fps = _positive_real(source_fps, "target video fps")
+ target_fps = _positive_real(target_fps, "model video fps")
+ frames = payload
+ if source_fps != target_fps:
+ # Match H3's official ffmpeg-style fps filter: each source frame is held
+ # until the rounded slot of the next frame, including the stream endpoint.
+ scale = target_fps / source_fps
+ slots = np.floor(np.arange(frames.shape[0]) * scale + 0.5).astype(np.int64)
+ endpoint = math.floor(frames.shape[0] * scale + 0.5)
+ frames = np.repeat(frames, np.diff(slots, append=endpoint), axis=0)
+ if frames.shape[0] < target_frames:
+ required_duration = target_frames / target_fps
+ available_duration = frames.shape[0] / target_fps
+ raise ValueError(
+ "MiniMax H3 target video is too short for configured temporal geometry: "
+ f"requires {target_frames} frames/{required_duration:.6f}s after rate conversion, "
+ f"has {frames.shape[0]} frames/{available_duration:.6f}s"
+ )
+ frames = np.ascontiguousarray(frames[:target_frames])
+ if frames.shape[1:3] != (height, width):
+ frames = np.stack(
+ [
+ np.asarray(Image.fromarray(frame).resize((width, height), Image.Resampling.LANCZOS))
+ for frame in frames
+ ]
+ )
+ pixels = torch.from_numpy(np.ascontiguousarray(frames)).permute(3, 0, 1, 2).unsqueeze(0)
+ return pixels.to(torch.float32).div_(255.0)
+
+
+def encode_h3_target_video(
+ adapter: Any,
+ pixel_values: torch.Tensor,
+ *,
+ generator: Optional[torch.Generator],
+) -> torch.Tensor:
+ """Apply the framework's sampled-posterior policy for clean H3 targets.
+
+ Diffusers specifies a fixed-seed sample followed by float16 rounding only for
+ H3 *conditions*. Offline clean targets instead use the caller's generator and
+ retain the sampled posterior in float32 before normalization. This distinction
+ is an explicit framework inference from latent training, not an official H3
+ target-encoding recipe.
+
+ Args:
+ adapter: Active MiniMax H3 adapter exposing the video VAE.
+ pixel_values: Normalized-shape pixels ``(1, 3, F, H, W)`` in ``[0, 1]``.
+ generator: Optional posterior sampling generator.
+
+ Returns:
+ Normalized video latents shaped ``(1, 24, F', H', W')``.
+ """
+ if not isinstance(pixel_values, torch.Tensor) or pixel_values.ndim != 5:
+ raise ValueError(
+ "MiniMax H3 target video pixels expected BCFHW tensor, "
+ f"received {type(pixel_values).__name__}/{getattr(pixel_values, 'shape', None)}"
+ )
+ vae = adapter.get_component("vae")
+ device = torch.device(adapter.device)
+ pixel_mean = torch.as_tensor(
+ adapter.pipeline.pixel_mean,
+ device=device,
+ dtype=torch.float32,
+ ).view(1, -1, 1, 1, 1)
+ pixel_std = torch.as_tensor(
+ adapter.pipeline.pixel_std,
+ device=device,
+ dtype=torch.float32,
+ ).view(1, -1, 1, 1, 1)
+ normalized_pixels = (
+ pixel_values.to(device=device, dtype=torch.float32) - pixel_mean
+ ) / pixel_std
+ encoded = vae.encode(normalized_pixels)
+ latents = retrieve_vae_latents(
+ encoded,
+ sample_mode="sample",
+ generator=generator,
+ source="MiniMax H3 target video",
+ ).to(torch.float32)
+ latent_mean, latent_std = _latent_statistics(
+ vae.config,
+ channels=latents.shape[1],
+ rank=5,
+ device=latents.device,
+ source="MiniMax H3 video VAE",
+ )
+ return (latents - latent_mean) / latent_std
+
+
+def prepare_h3_target_audio(
+ payload: Any,
+ *,
+ source_sample_rate: Any,
+ target_sample_rate: int,
+ target_samples: int,
+ target_duration_seconds: float,
+) -> torch.Tensor:
+ """Convert one waveform to exact stereo H3 audio-grid geometry.
+
+ The source-clock truncation precedes resampling, matching Diffusers' H3
+ reference normalization. The final trim/pad then accounts for audio-latent
+ rounding so the waveform lands on the exact model grid.
+
+ Args:
+ payload: Decoded mono or stereo float waveform shaped ``(C, S)``.
+ source_sample_rate: Logical source clock from the media manifest.
+ target_sample_rate: Audio VAE sample rate.
+ target_samples: Exact number of samples required by the audio latent grid.
+ target_duration_seconds: Aligned AV duration used for source-clock truncation.
+
+ Returns:
+ Contiguous float32 stereo waveform shaped ``(2, target_samples)``.
+ """
+ if not isinstance(payload, torch.Tensor):
+ raise TypeError(
+ "MiniMax H3 target audio expected a decoded torch.Tensor, "
+ f"received {type(payload).__name__}"
+ )
+ if payload.ndim != 2 or payload.shape[0] not in (1, 2) or payload.shape[1] < 1:
+ raise ValueError(
+ "MiniMax H3 target audio must be non-empty mono/stereo shaped (C,S), "
+ f"received {tuple(payload.shape)}"
+ )
+ if not payload.is_floating_point():
+ raise TypeError(
+ f"MiniMax H3 target audio expected floating waveform, received {payload.dtype}"
+ )
+ if not torch.isfinite(payload).all():
+ raise ValueError("MiniMax H3 target audio contains non-finite samples")
+ source_sample_rate = _positive_int(source_sample_rate, "target audio sample_rate")
+ target_sample_rate = _positive_int(target_sample_rate, "model audio sample_rate")
+ target_samples = _positive_int(target_samples, "target audio samples")
+ target_duration_seconds = _positive_real(
+ target_duration_seconds,
+ "target audio duration",
+ )
+ source_samples = int(target_duration_seconds * source_sample_rate)
+ if source_samples < 1:
+ raise ValueError(
+ "MiniMax H3 target audio duration resolves to fewer than one source sample: "
+ f"duration={target_duration_seconds}, sample_rate={source_sample_rate}"
+ )
+ source_waveform = payload.detach().to(device="cpu", dtype=torch.float32)[:, :source_samples]
+ waveform = convert_audio(
+ source_waveform,
+ from_rate=source_sample_rate,
+ to_rate=target_sample_rate,
+ to_channels=2,
+ )
+ if waveform.shape[-1] >= target_samples:
+ waveform = waveform[:, :target_samples]
+ else:
+ waveform = torch.nn.functional.pad(waveform, (0, target_samples - waveform.shape[-1]))
+ return waveform.contiguous()
+
+
+def encode_h3_target_audio(adapter: Any, waveform: torch.Tensor) -> torch.Tensor:
+ """Take and normalize the official H3 audio posterior mode.
+
+ Args:
+ adapter: Active MiniMax H3 adapter exposing the audio VAE.
+ waveform: Exact stereo model-rate waveform shaped ``(2, S)``.
+
+ Returns:
+ Normalized channel-major audio latents shaped ``(2, 32, F)``.
+ """
+ audio_vae = adapter.get_component("audio_vae")
+ device = torch.device(adapter.device)
+ posterior = audio_vae.encode(waveform.to(device=device)[:, None])
+ latents = retrieve_vae_latents(
+ posterior,
+ sample_mode="argmax",
+ source="MiniMax H3 target audio",
+ ).to(torch.float32)
+ latent_mean, latent_std = _latent_statistics(
+ audio_vae.config,
+ channels=latents.shape[1],
+ rank=3,
+ device=latents.device,
+ source="MiniMax H3 audio VAE",
+ )
+ return (latents - latent_mean) / latent_std
+
+
+def _validate_h3_t2va_input_layout(
+ condition: Mapping[str, Any],
+ clean_state: LatentState,
+) -> None:
+ """Validate the authoritative flat T2VA layout retained in input conditions."""
+ layout = _normalize_layout(condition)
+ missing = tuple(field for field in _LAYOUT_FIELDS if field not in layout)
+ if missing:
+ raise ValueError(f"MiniMax H3 offline condition layout missing fields={missing}")
+
+ position_ids = layout["position_ids"]
+ token_tags = layout["token_tags"]
+ index_tensors = [layout[field] for field in ("video_indices", "audio_indices", "text_indices")]
+ if (
+ not isinstance(position_ids, torch.Tensor)
+ or position_ids.ndim != 2
+ or position_ids.shape[-1] != 3
+ or position_ids.dtype != torch.float64
+ ):
+ raise ValueError(
+ "MiniMax H3 T2VA position_ids expected float64 shape (N,3), "
+ f"received {type(position_ids).__name__}/{getattr(position_ids, 'shape', None)}/"
+ f"{getattr(position_ids, 'dtype', None)}"
+ )
+ if (
+ not isinstance(token_tags, torch.Tensor)
+ or token_tags.ndim != 1
+ or token_tags.dtype != torch.long
+ ):
+ raise ValueError(
+ "MiniMax H3 T2VA token_tags expected one-dimensional torch.long, "
+ f"received {type(token_tags).__name__}/{getattr(token_tags, 'shape', None)}/"
+ f"{getattr(token_tags, 'dtype', None)}"
+ )
+ for field, values in zip(("video_indices", "audio_indices", "text_indices"), index_tensors):
+ if not isinstance(values, torch.Tensor) or values.ndim != 1 or values.dtype != torch.long:
+ raise ValueError(
+ f"MiniMax H3 T2VA {field} expected one-dimensional torch.long, "
+ f"received {type(values).__name__}/{getattr(values, 'shape', None)}/"
+ f"{getattr(values, 'dtype', None)}"
+ )
+
+ for component in ("video", "audio"):
+ count_field = f"num_condition_{component}_rows"
+ if layout[count_field] != 0:
+ raise ValueError(
+ "MiniMax H3 T2VA offline layout requires no condition rows, "
+ f"received {count_field}={layout[count_field]}"
+ )
+ component_indices = layout[f"{component}_indices"]
+ expected_rows = clean_state.components[component].shape[1]
+ if component_indices.numel() != expected_rows:
+ raise ValueError(
+ f"MiniMax H3 T2VA {component} layout expected {expected_rows} target rows, "
+ f"received {component_indices.numel()} indices"
+ )
+
+ sequence_length = sum(values.numel() for values in index_tensors)
+ if position_ids.shape[0] != sequence_length or token_tags.numel() != sequence_length:
+ raise ValueError(
+ "MiniMax H3 T2VA flat layout sequence lengths disagree: "
+ f"indices={sequence_length}, position_ids={position_ids.shape[0]}, "
+ f"token_tags={token_tags.numel()}"
+ )
+ devices = {
+ position_ids.device,
+ token_tags.device,
+ *(values.device for values in index_tensors),
+ }
+ if len(devices) != 1:
+ raise ValueError(
+ f"MiniMax H3 T2VA flat layout tensors must share one device, got {devices}"
+ )
+ permutation = torch.cat(index_tensors).sort().values
+ expected_permutation = torch.arange(
+ sequence_length,
+ dtype=torch.long,
+ device=permutation.device,
+ )
+ if not torch.equal(permutation, expected_permutation):
+ raise ValueError(
+ "MiniMax H3 T2VA video/audio/text indices must partition the packed sequence"
+ )
+
+
+def validate_h3_encoded_output_geometry(
+ adapter: Any,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+) -> None:
+ """Prove codec geometry and contexts agree with the cached T2VA layout.
+
+ Args:
+ adapter: Active MiniMax H3 adapter.
+ media_batch: Validated exact audiovisual target batch.
+ condition: Cached input condition with geometry and packed-row layout.
+ encoded: Encoded structured target state to validate.
+
+ Returns:
+ None after every geometry, component, and context invariant is proven.
+ """
+ geometry = resolve_h3_output_geometry(adapter, condition)
+ model_shape = _resolve_h3_model_shape(adapter)
+ audio_vae = adapter.get_component("audio_vae")
+ sample_rate = _positive_int(
+ getattr(audio_vae.config, "sampling_rate", None),
+ "audio_vae.config.sampling_rate",
+ )
+ hop_length = _positive_int(
+ getattr(audio_vae, "hop_length", None),
+ "audio_vae.hop_length",
+ )
+ expected_signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=geometry["height"],
+ width=geometry["width"],
+ frames=geometry["num_frames"],
+ fps=float(adapter.pipeline.fps),
+ ),
+ MediaGeometrySignature(
+ type=MediaType.AUDIO,
+ samples=geometry["num_audio_latents"] * hop_length,
+ sample_rate=sample_rate,
+ ),
+ )
+ )
+ if len(media_batch) != 1 or encoded.geometry_signatures != (expected_signature,):
+ raise ValueError(
+ "MiniMax H3 encoded output geometry disagrees with configured audiovisual geometry: "
+ f"expected {(expected_signature,)!r}, received {encoded.geometry_signatures!r}"
+ )
+ if encoded.decode_context.get("geometry") != geometry:
+ raise ValueError(
+ "MiniMax H3 decode_context geometry disagrees with cached condition geometry: "
+ f"expected {geometry!r}, received {encoded.decode_context.get('geometry')!r}"
+ )
+
+ validate_target_state(encoded.clean_state)
+ if encoded.clean_state.component_names != ("video", "audio"):
+ raise ValueError(
+ "MiniMax H3 clean target components must be ordered ('video', 'audio'), "
+ f"received {encoded.clean_state.component_names}"
+ )
+ patch_time, patch_height, patch_width = model_shape.patch_size
+ expected_shapes = {
+ "video": (
+ 1,
+ geometry["num_latent_frames"]
+ // patch_time
+ * (geometry["latent_height"] // patch_height)
+ * (geometry["latent_width"] // patch_width),
+ model_shape.video_latent_channels * patch_time * patch_height * patch_width,
+ ),
+ "audio": (
+ 1,
+ model_shape.audio_channels * geometry["num_audio_latents"],
+ model_shape.audio_latent_channels,
+ ),
+ }
+ for component, expected_shape in expected_shapes.items():
+ received_shape = tuple(encoded.clean_state.components[component].shape)
+ if received_shape != expected_shape:
+ raise ValueError(
+ f"MiniMax H3 clean {component} rows expected shape {expected_shape}, "
+ f"received {received_shape}"
+ )
+
+ if encoded.forward_context:
+ raise ValueError(
+ "MiniMax H3 T2VA output codec must not duplicate input-owned layout/prefix fields, "
+ f"received keys={tuple(encoded.forward_context)}"
+ )
+ _validate_h3_t2va_input_layout(condition, encoded.clean_state)
+
+
+def _resolve_h3_model_shape(adapter: Any) -> _H3ModelShape:
+ """Validate the packing dimensions of the released H3 checkpoint contract."""
+ pipeline = adapter.pipeline
+ patch_size = getattr(pipeline, "patch_size", None)
+ if not isinstance(patch_size, (tuple, list)) or len(patch_size) != 3:
+ raise TypeError(
+ "pipeline.patch_size expected a length-3 tuple/list, "
+ f"received {type(patch_size).__name__}: {patch_size!r}"
+ )
+ patch_size = tuple(
+ _positive_int(value, f"pipeline.patch_size[{index}]")
+ for index, value in enumerate(patch_size)
+ )
+ video_latent_channels = _positive_int(
+ getattr(pipeline, "vae_latent_channels", None),
+ "pipeline.vae_latent_channels",
+ )
+ audio_channels = _positive_int(
+ getattr(pipeline, "audio_channels", None),
+ "pipeline.audio_channels",
+ )
+ audio_latent_channels = _positive_int(
+ getattr(pipeline, "audio_latent_channels", None),
+ "pipeline.audio_latent_channels",
+ )
+ received = (
+ patch_size,
+ video_latent_channels,
+ audio_channels,
+ audio_latent_channels,
+ )
+ expected = (
+ _RELEASED_PATCH_SIZE,
+ _RELEASED_VIDEO_LATENT_CHANNELS,
+ _RELEASED_AUDIO_CHANNELS,
+ _RELEASED_AUDIO_LATENT_CHANNELS,
+ )
+ if received != expected:
+ raise ValueError(
+ "MiniMax H3 output codec supports the released packing contract "
+ "patch/video_channels/audio_channels/audio_latent_channels="
+ f"{expected!r}, received {received!r}"
+ )
+ return _H3ModelShape(
+ patch_size=patch_size,
+ video_latent_channels=video_latent_channels,
+ audio_channels=audio_channels,
+ audio_latent_channels=audio_latent_channels,
+ )
+
+
+def _latent_statistics(
+ config: Any,
+ *,
+ channels: int,
+ rank: int,
+ device: torch.device,
+ source: str,
+) -> Tuple[torch.Tensor, torch.Tensor]:
+ raw_mean = getattr(config, "latents_mean", None)
+ raw_std = getattr(config, "latents_std", None)
+ if raw_mean is None or raw_std is None:
+ raise ValueError(f"{source} config must define latents_mean and latents_std")
+ mean = torch.as_tensor(raw_mean, device=device, dtype=torch.float32)
+ std = torch.as_tensor(raw_std, device=device, dtype=torch.float32)
+ if mean.shape != (channels,) or std.shape != (channels,):
+ raise ValueError(
+ f"{source} expected per-channel latent statistics shaped ({channels},), "
+ f"received mean={tuple(mean.shape)}, std={tuple(std.shape)}"
+ )
+ if not torch.isfinite(mean).all() or not torch.isfinite(std).all() or torch.any(std <= 0):
+ raise ValueError(f"{source} latent statistics must be finite with strictly positive std")
+ shape = [1, channels, *([1] * (rank - 2))]
+ return mean.view(shape), std.view(shape)
+
+
+def _positive_int(value: Any, source: str) -> int:
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
+ raise ValueError(f"{source} expected positive int, received {value!r}")
+ return value
+
+
+def _positive_real(value: Any, source: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, Real):
+ raise TypeError(
+ f"{source} expected positive finite real, received {type(value).__name__}: {value!r}"
+ )
+ value = float(value)
+ if not math.isfinite(value) or value <= 0:
+ raise ValueError(f"{source} expected positive finite real, received {value!r}")
+ return value
+
+
+__all__ = [
+ "MiniMaxH3AVOutputCodec",
+ "encode_h3_target_audio",
+ "encode_h3_target_video",
+ "prepare_h3_target_audio",
+ "prepare_h3_target_video",
+ "resolve_h3_output_geometry",
+ "validate_h3_encoded_output_geometry",
+]
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index ca2650573..6c0a001c3 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -18,6 +18,11 @@
import torch
+from ...contracts import (
+ BatchCapability,
+ GeometrySource,
+ NegativePromptPolicy,
+)
from ...samples import (
ComponentTimes,
LatentState,
@@ -28,8 +33,11 @@
from ...scheduler import MiniMaxH3SDEScheduler, SchedulerGroup
from ..abc import BaseAdapter
from ..checkpointing import CheckpointUnit
+from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
+from ..pipeline_contracts import audio_video_output_contract
from ..runtime import ModularPipelineRuntime
from ._common import apply_forward_process_noise, draw_forward_process_noise
+from ._output import MiniMaxH3AVOutputCodec, validate_h3_encoded_output_geometry
from .workflow import (
build_h3_component_runtime,
build_h3_replay_forward_kwargs,
@@ -183,7 +191,11 @@ def _forward_state(
compute_log_prob=compute_log_prob,
return_fields=return_fields,
noise_level=noise_level,
- **build_h3_replay_forward_kwargs(forward_kwargs),
+ **build_h3_replay_forward_kwargs(
+ forward_kwargs,
+ state=state,
+ workflow=self.workflow,
+ ),
)
def forward(self, **kwargs: Any) -> MultiModalStepOutput:
@@ -193,9 +205,10 @@ def forward(self, **kwargs: Any) -> MultiModalStepOutput:
class MiniMaxH3T2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 text-to-video-audio partition."""
- output_state_codec_unavailable_reason = (
- "MiniMax H3 offline targets require lossless audiovisual decoding/alignment, and the "
- "official target-video posterior policy is not defined by the inference encoders"
+ pipeline_io_contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
)
workflow: ClassVar[str] = "t2va"
@@ -208,13 +221,50 @@ class MiniMaxH3T2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"audio_vae",
]
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Declare the configured audiovisual target codec without loading components.
+
+ Returns:
+ Immutable MiniMax H3 audiovisual output codec declaration.
+ """
+ return MiniMaxH3AVOutputCodec(self)
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Require encoded rows and rate metadata to match cached T2VA geometry."""
+ validate_h3_encoded_output_geometry(self, media_batch, condition, encoded)
+
+ def _decode_output_state(
+ self,
+ encoded: EncodedOutputState,
+ *,
+ output_type: Literal["pil", "pt", "np"],
+ ) -> Any:
+ """Decode the two-component target state through H3's existing decoder."""
+ geometry = encoded.decode_context.get("geometry")
+ if not isinstance(geometry, Mapping):
+ raise TypeError(
+ "MiniMax H3 decode_context requires a geometry mapping, "
+ f"received {type(geometry).__name__}: {geometry!r}"
+ )
+ return self.decode_latents(
+ encoded.clean_state,
+ geometry=geometry,
+ output_type=output_type,
+ )
+
class MiniMaxH3FL2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 first/last-frame partition."""
output_state_codec_unavailable_reason = (
- "MiniMax H3 offline targets require lossless audiovisual decoding/alignment, and the "
- "official target-video posterior policy is not defined by the inference encoders"
+ "MiniMax H3 conditioned offline forward requires a shared, reproducible "
+ "conditioned-prefix binder; paired offline-DPO arms must reuse identical "
+ "condition posterior noise"
)
workflow: ClassVar[str] = "fl2va"
@@ -238,8 +288,9 @@ class MiniMaxH3Ref2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 omni-reference partition."""
output_state_codec_unavailable_reason = (
- "MiniMax H3 offline targets require lossless audiovisual decoding/alignment, and the "
- "official target-video posterior policy is not defined by the inference encoders"
+ "MiniMax H3 conditioned offline forward requires a shared, reproducible "
+ "conditioned-prefix binder; paired offline-DPO arms must reuse identical "
+ "condition posterior noise"
)
workflow: ClassVar[str] = "ref2va"
diff --git a/src/flow_factory/models/minimax_h3/workflow.py b/src/flow_factory/models/minimax_h3/workflow.py
index 5edb05f7b..abc53f797 100644
--- a/src/flow_factory/models/minimax_h3/workflow.py
+++ b/src/flow_factory/models/minimax_h3/workflow.py
@@ -25,6 +25,7 @@
MiniMaxH3FL2VASample,
MiniMaxH3Ref2VASample,
MiniMaxH3T2VASample,
+ MultiModalStepOutput,
StructuredTrajectory,
)
from ...scheduler import MiniMaxH3SDEScheduler, SchedulerGroup
@@ -475,7 +476,10 @@ def forward_h3_adapter_state(
f"received prompt B={prompt_embeds.shape[0]}"
)
layout = _normalize_layout({"layout": forward_kwargs["layout"]})
- return forward_h3_state(
+ velocity_only = (
+ not compute_log_prob and next_state is None and tuple(return_fields) == ("velocity",)
+ )
+ result = forward_h3_state(
adapter.get_component(adapter.transformer_component_name),
state,
condition_prefixes,
@@ -488,29 +492,79 @@ def forward_h3_adapter_state(
generator=forward_kwargs.get("generator"),
noise_level=noise_level,
compute_log_prob=compute_log_prob,
+ velocity_only=velocity_only,
attention_kwargs=forward_kwargs.get("attention_kwargs"),
return_kwargs=return_fields,
workflow=adapter.workflow,
)
+ if velocity_only:
+ if not isinstance(result, LatentState):
+ raise TypeError(
+ "MiniMax H3 velocity-only forward expected LatentState, "
+ f"received {type(result).__name__}"
+ )
+ return MultiModalStepOutput(velocity=result)
+ return result
-def build_h3_replay_forward_kwargs(forward_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
+def build_h3_replay_forward_kwargs(
+ forward_kwargs: Mapping[str, Any],
+ *,
+ state: Optional[LatentState] = None,
+ workflow: Optional[str] = None,
+) -> Dict[str, Any]:
"""Select the conditioning arguments H3 ``forward`` accepts from a replay batch.
Replay wrappers receive every collated batch field, while ``forward`` is a strict
- public boundary. Selecting here keeps that boundary strict for rollout callers.
+ public boundary. Offline T2VA cache rows retain their authoritative layout as flat
+ input fields and have no condition latents. This binder nests that layout and builds
+ empty prefixes from the current state, so storage casts cannot leave prefix dtype or
+ device stale. Online replay already supplies both nested fields and keeps that path.
Args:
forward_kwargs: Conditioning arguments resolved from the stored batch.
+ state: Current target state, required to bind missing T2VA prefixes.
+ workflow: H3 workflow identifier, required when prefixes are missing.
Returns:
Conditioning arguments accepted by ``forward``.
"""
- return {
+ selected = {
name: value
for name, value in forward_kwargs.items()
if name in _H3_FORWARD_CONDITIONING_FIELDS
}
+ if "layout" not in selected:
+ layout = _normalize_layout(forward_kwargs)
+ required_layout_fields = (
+ *_LAYOUT_MATRIX_FIELDS,
+ *_LAYOUT_INDEX_FIELDS,
+ *_LAYOUT_COUNT_FIELDS,
+ )
+ missing = tuple(field for field in required_layout_fields if field not in layout)
+ if missing:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} replay flat layout missing fields={missing}"
+ )
+ selected["layout"] = layout
+
+ if "condition_prefixes" not in selected:
+ if workflow != "t2va":
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} replay requires a shared, reproducible "
+ "conditioned-prefix binder"
+ )
+ if not isinstance(state, LatentState):
+ raise TypeError(
+ "MiniMax H3 T2VA replay requires LatentState to bind empty prefixes, "
+ f"received {type(state).__name__}"
+ )
+ validate_target_state(state)
+ selected["condition_prefixes"] = {
+ component: values.new_empty((values.shape[0], 0, values.shape[-1]))
+ for component, values in state.components.items()
+ }
+ return selected
def forward_h3_adapter(adapter: Any, **kwargs: Any) -> Any:
@@ -901,8 +955,10 @@ def _normalize_b1_integer(value: Any, field: str) -> int:
def _decoded_video_sample(video: Any) -> Any:
if isinstance(video, torch.Tensor) and video.shape[0] == 1:
return video[0]
- if isinstance(video, list) and len(video) == 1 and (
- video[0] is None or isinstance(video[0], list)
+ if (
+ isinstance(video, list)
+ and len(video) == 1
+ and (video[0] is None or isinstance(video[0], list))
):
return video[0]
return video
diff --git a/src/flow_factory/models/pipeline_contracts.py b/src/flow_factory/models/pipeline_contracts.py
index f8fe1cce9..3903811d4 100644
--- a/src/flow_factory/models/pipeline_contracts.py
+++ b/src/flow_factory/models/pipeline_contracts.py
@@ -16,7 +16,7 @@
from __future__ import annotations
-from typing import Optional
+from typing import Optional, Tuple
from ..contracts import (
BatchCapability,
@@ -158,11 +158,67 @@ def video_output_contract(
)
+def audio_video_output_contract(
+ *,
+ negative_prompt: NegativePromptPolicy,
+ input_rules: Tuple[InputMediaRule, ...] = (),
+ input_binding: InputMediaBinding = InputMediaBinding.GROUPED_BY_TYPE,
+ input_order: InputMediaOrder = InputMediaOrder.INSENSITIVE,
+ output_fps: RateRequirement = RateRequirement.REQUIRED,
+ output_sample_rate: RateRequirement = RateRequirement.REQUIRED,
+ geometry_source: GeometrySource = GeometrySource.OUTPUT_MEDIA,
+ batch_capability: BatchCapability = BatchCapability.SINGLE_SAMPLE,
+) -> PipelineIOContract:
+ """Build an exact ordered video-and-audio output declaration.
+
+ Supplying explicit input rules keeps this constructor neutral to how a model
+ binds conditions: prompt-only, grouped image conditions, and globally ordered
+ heterogeneous references all use the same output contract. Cross-type total
+ cardinality constraints remain adapter-owned because ``InputMediaSpec``
+ represents per-type bounds only.
+
+ Args:
+ negative_prompt: Whether negative prompts are unsupported, optional, or required.
+ input_rules: Canonically ordered per-type input-media rules.
+ input_binding: How input media are projected into model-facing arguments.
+ input_order: Which input-media ordering carries semantic meaning.
+ output_fps: Whether target-video frame-rate metadata is accepted or required.
+ output_sample_rate: Whether target-audio sample-rate metadata is accepted or required.
+ geometry_source: Boundary that determines aligned output geometry.
+ batch_capability: Whether the adapter accepts uniform, ragged, or single-sample batches.
+
+ Returns:
+ Immutable pipeline I/O contract with exact output order ``(video, audio)``.
+ """
+ video_format = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=output_fps,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+ audio_format = MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=output_sample_rate,
+ )
+ return PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=input_rules,
+ binding=input_binding,
+ order=input_order,
+ ),
+ negative_prompt=negative_prompt,
+ output_media=OutputMediaSequence(items=(video_format, audio_format)),
+ geometry_source=geometry_source,
+ batch_capability=batch_capability,
+ )
+
+
__all__ = [
"AUDIO_FORMAT_REQUIRED_RATE",
"IMAGE_FORMAT",
"VIDEO_FORMAT_OPTIONAL_FPS",
"VIDEO_FORMAT_REQUIRED_FPS",
+ "audio_video_output_contract",
"image_output_contract",
"video_output_contract",
]
diff --git a/tests/data_utils/test_offline_dataset.py b/tests/data_utils/test_offline_dataset.py
index 093c9db38..23ce8b16f 100644
--- a/tests/data_utils/test_offline_dataset.py
+++ b/tests/data_utils/test_offline_dataset.py
@@ -38,6 +38,7 @@
PreferenceOutputBatch,
compute_offline_condition_id,
compute_offline_record_id,
+ decode_audio,
decode_video,
load_offline_manifest,
)
@@ -867,16 +868,28 @@ def test_collator_uses_declared_supervision_instead_of_first_item_union(tmp_path
OfflineCollator("demonstration")([demonstration_dataset[0], preference_dataset[0]])
-def test_unsupported_audio_output_fails_explicitly(tmp_path: Path) -> None:
+def test_default_audio_decoder_preserves_samples_and_source_clock_override(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ target_path = tmp_path / "target.wav"
+ target_path.write_bytes(b"identity-only audio payload")
+ calls: List[tuple[str, int | None]] = []
+
+ def fake_load_audio(path: str, sample_rate: int | None = None) -> torch.Tensor:
+ calls.append((str(path), sample_rate))
+ return torch.arange(12, dtype=torch.float64).reshape(2, 6).requires_grad_()
+
+ monkeypatch.setattr(offline_dataset_module, "load_audio", fake_load_audio)
media: Dict[str, Any] = {
"type": "audio",
- "path": "target.audio",
+ "path": "target.wav",
"sample_rate": 16000,
}
record = normalize_v2_record(
{
"schema_version": 2,
- "input": {"prompt": "unsupported", "media": []},
+ "input": {"prompt": "audio target", "media": []},
"supervision": {
"type": "demonstration",
"target": {"media": [media]},
@@ -884,14 +897,27 @@ def test_unsupported_audio_output_fails_explicitly(tmp_path: Path) -> None:
},
dataset_dir=tmp_path,
)
- with pytest.raises(NotImplementedError, match=r"type 'audio'.*no decoder"):
- OfflineDataset(
- [record],
- _condition_cache([record], [{"prompt_embeds": torch.ones(2)}]),
- source_name=SOURCE_NAME,
- source_id=SOURCE_ID,
- supervision_type="demonstration",
- )
+ dataset = OfflineDataset(
+ [record],
+ _condition_cache([record], [{"prompt_embeds": torch.ones(2)}]),
+ source_name=SOURCE_NAME,
+ source_id=SOURCE_ID,
+ supervision_type="demonstration",
+ )
+
+ item = dataset[0]
+
+ assert isinstance(item.output, DemonstrationOutput)
+ decoded = item.output.target_media[0]
+ assert calls == [(str(target_path), None)]
+ assert decoded.payload.shape == (2, 6)
+ assert decoded.payload.dtype is torch.float32
+ assert decoded.payload.device.type == "cpu"
+ assert decoded.payload.is_contiguous()
+ assert decoded.payload.requires_grad is False
+ assert decoded.sample_rate == 16000
+ assert pickle.loads(pickle.dumps(decode_audio)) is decode_audio
+ pickle.dumps(dataset)
def test_default_video_decoder_returns_diffusers_compatible_cpu_frames(tmp_path: Path) -> None:
diff --git a/tests/data_utils/test_offline_train_data.py b/tests/data_utils/test_offline_train_data.py
index 7c9a3d960..42982bdea 100644
--- a/tests/data_utils/test_offline_train_data.py
+++ b/tests/data_utils/test_offline_train_data.py
@@ -142,6 +142,7 @@ def prepare(self, *args: Any) -> None:
class _CountingPreprocessor:
def __init__(self) -> None:
self.calls = 0
+ self.batch_sizes: List[int] = []
self.is_train: bool | None = None
self.guidance_scale: float | None = None
@@ -153,6 +154,7 @@ def preprocess(
guidance_scale: float,
) -> Dict[str, torch.Tensor]:
self.calls += 1
+ self.batch_sizes.append(len(prompt))
self.is_train = is_train
self.guidance_scale = guidance_scale
return {"prompt_embeds": torch.ones(len(prompt), 2)}
@@ -583,6 +585,37 @@ def test_builder_rejects_single_sample_pipeline_batching_before_dataset_io(
assert preprocessor.calls == 0
+def test_single_sample_contract_forces_condition_preprocessing_batch_size_one(
+ tmp_path: Path,
+) -> None:
+ """Single-sample adapters must also preprocess their condition cache at B=1."""
+ dataset_dir = tmp_path / "single-sample-cache"
+ dataset_dir.mkdir()
+ rows = []
+ for index in range(2):
+ target = f"target-{index}.png"
+ _write_image(dataset_dir / target, index)
+ rows.append(_demonstration_row(f"prompt-{index}", target))
+ _write_manifest(dataset_dir, rows)
+ contract = replace(
+ _TEXT_TO_IMAGE_CONTRACT,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
+ preprocessor = _CountingPreprocessor()
+
+ build_offline_train_dataloader(
+ _config(tmp_path, [_source("single-sample-cache", dataset_dir, 0)]),
+ _Accelerator(),
+ preprocessor.preprocess,
+ supervision_type="demonstration",
+ pipeline_io_contract=contract,
+ shuffle=False,
+ )
+
+ assert preprocessor.calls == 2
+ assert preprocessor.batch_sizes == [1, 1]
+
+
def test_builder_rejects_non_unit_weight_and_unresolved_source_id_before_io(
tmp_path: Path,
) -> None:
@@ -610,7 +643,17 @@ def test_builder_rejects_non_unit_weight_and_unresolved_source_id_before_io(
def test_builder_rejects_missing_target_decoder_before_condition_preprocessing(
tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
+ monkeypatch.setattr(
+ offline_train_data,
+ "DEFAULT_MEDIA_DECODERS",
+ {
+ media_type: decoder
+ for media_type, decoder in offline_dataset_module.DEFAULT_MEDIA_DECODERS.items()
+ if media_type != "audio"
+ },
+ )
dataset_dir = tmp_path / "audio"
_write_manifest(
dataset_dir,
diff --git a/tests/models/minimax_h3/test_output_codec.py b/tests/models/minimax_h3/test_output_codec.py
new file mode 100644
index 000000000..fceacbff9
--- /dev/null
+++ b/tests/models/minimax_h3/test_output_codec.py
@@ -0,0 +1,604 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for MiniMax H3 T2VA offline audiovisual target encoding."""
+
+from types import SimpleNamespace
+from typing import Any, Optional
+
+import numpy as np
+import pytest
+import torch
+
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ MediaType,
+ NegativePromptPolicy,
+ RateRequirement,
+)
+from flow_factory.data_utils.offline_dataset import DecodedMedia
+from flow_factory.models.minimax_h3._output import (
+ MiniMaxH3AVOutputCodec,
+ prepare_h3_target_audio,
+ prepare_h3_target_video,
+ resolve_h3_output_geometry,
+ validate_h3_encoded_output_geometry,
+)
+from flow_factory.models.minimax_h3.adapters import (
+ MiniMaxH3FL2VAAdapter,
+ MiniMaxH3Ref2VAAdapter,
+ MiniMaxH3T2VAAdapter,
+)
+from flow_factory.samples import ComponentTimes, LatentState
+from flow_factory.scheduler import MiniMaxH3SDEScheduler
+from flow_factory.trainers.common.flow_matching import (
+ build_noised_output_state,
+ flow_matching_per_sample_loss,
+ validate_preference_component_times,
+ validate_preference_output_states,
+)
+
+
+class _Posterior:
+ def __init__(self, values: torch.Tensor) -> None:
+ self.values = values
+ self.sample_generator: Optional[torch.Generator] = None
+ self.sample_calls = 0
+ self.mode_calls = 0
+
+ def sample(self, generator: Optional[torch.Generator] = None) -> torch.Tensor:
+ self.sample_generator = generator
+ self.sample_calls += 1
+ return self.values
+
+ def mode(self) -> torch.Tensor:
+ self.mode_calls += 1
+ return self.values
+
+
+class _VideoVAE:
+ def __init__(self, posterior: _Posterior) -> None:
+ self.posterior = posterior
+ self.config = SimpleNamespace(
+ latents_mean=[0.0] * 24,
+ latents_std=[1.0] * 24,
+ )
+ self.encoded_pixels: Optional[torch.Tensor] = None
+
+ def encode(self, pixels: torch.Tensor) -> Any:
+ self.encoded_pixels = pixels
+ return SimpleNamespace(latent_dist=self.posterior)
+
+
+class _AudioVAE:
+ hop_length = 800
+
+ def __init__(self, posterior: _Posterior) -> None:
+ self.posterior = posterior
+ self.config = SimpleNamespace(
+ sampling_rate=32000,
+ latents_mean=[0.0] * 32,
+ latents_std=[1.0] * 32,
+ )
+ self.encoded_waveform: Optional[torch.Tensor] = None
+
+ def encode(self, waveform: torch.Tensor) -> Any:
+ self.encoded_waveform = waveform
+ return SimpleNamespace(latent_dist=self.posterior)
+
+
+class _TinyH3Transformer(torch.nn.Module):
+ """Gradient-bearing stand-in that preserves H3's two-output contract."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.weight = torch.nn.Parameter(torch.tensor(0.25))
+ self.keyframe_noise_aug = 0.999
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ audio_hidden_states: torch.Tensor,
+ **kwargs: Any,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ return hidden_states * self.weight, audio_hidden_states * self.weight
+
+
+class _Adapter:
+ device = torch.device("cpu")
+
+ def __init__(self) -> None:
+ self.video_posterior = _Posterior(torch.full((1, 24, 7, 2, 2), 2.0005))
+ self.audio_posterior = _Posterior(torch.full((2, 32, 37), 3.0))
+ self.vae = _VideoVAE(self.video_posterior)
+ self.audio_vae = _AudioVAE(self.audio_posterior)
+ self.transformer = _TinyH3Transformer()
+ self.pipeline = SimpleNamespace(
+ fps=24,
+ pixel_mean=(0.485, 0.456, 0.406),
+ pixel_std=(0.229, 0.224, 0.225),
+ vae_spatial_compression_ratio=16,
+ vae_frames_per_chunk=17,
+ vae_latents_per_chunk=5,
+ vae_latent_channels=24,
+ patch_size=(1, 2, 2),
+ audio_channels=2,
+ audio_latent_channels=32,
+ audio_sampling_rate=32000,
+ min_duration=0.5,
+ max_duration=15.0,
+ )
+ self.training_args = SimpleNamespace(
+ height=32,
+ width=32,
+ num_frames=22,
+ frame_rate=24.0,
+ )
+
+ def get_component(self, name: str) -> Any:
+ return getattr(self, name)
+
+
+def _base_adapter(*, latent_storage_dtype: str) -> MiniMaxH3T2VAAdapter:
+ components = _Adapter()
+ adapter = object.__new__(MiniMaxH3T2VAAdapter)
+ adapter.accelerator = SimpleNamespace(device=torch.device("cpu"))
+ adapter.training_args = SimpleNamespace(
+ height=32,
+ width=32,
+ num_frames=22,
+ frame_rate=24.0,
+ latent_storage_dtype=latent_storage_dtype,
+ )
+ adapter.pipeline = components.pipeline
+ adapter.component_runtime = SimpleNamespace(
+ get_component=lambda name: components.get_component(name)
+ )
+ adapter.scheduler = MiniMaxH3SDEScheduler(
+ shift=12.0,
+ dynamics_type="Flow-SDE",
+ sde_steps=[0, 1],
+ num_sde_steps=2,
+ )
+ adapter.audio_scheduler = MiniMaxH3SDEScheduler(
+ shift=3.0,
+ dynamics_type="Flow-SDE",
+ sde_steps=[0, 1],
+ num_sde_steps=2,
+ )
+ adapter.scheduler.set_timesteps(2, device="cpu")
+ adapter.audio_scheduler.set_timesteps(2, device="cpu")
+ adapter._output_state_codec = adapter.build_output_state_codec()
+ return adapter
+
+
+def _condition() -> dict[str, Any]:
+ text_rows = 2
+ audio_rows = 74
+ video_rows = 7
+ sequence_length = text_rows + audio_rows + video_rows
+ return {
+ "height": [32],
+ "width": [32],
+ "num_frames": [22],
+ "num_latent_frames": [7],
+ "latent_height": [2],
+ "latent_width": [2],
+ "num_audio_latents": [37],
+ "position_ids": torch.zeros(1, sequence_length, 3, dtype=torch.float64),
+ "token_tags": torch.zeros(1, sequence_length, dtype=torch.long),
+ "text_indices": torch.arange(text_rows).unsqueeze(0),
+ "audio_indices": torch.arange(text_rows, text_rows + audio_rows).unsqueeze(0),
+ "video_indices": torch.arange(text_rows + audio_rows, sequence_length).unsqueeze(0),
+ "num_condition_video_rows": [0],
+ "num_condition_audio_rows": [0],
+ }
+
+
+def _media(*, audio_samples: int = 37 * 800) -> tuple[tuple[DecodedMedia, ...], ...]:
+ return (
+ (
+ DecodedMedia(
+ type="video",
+ path="target.mp4",
+ payload=np.zeros((22, 32, 32, 3), dtype=np.uint8),
+ fps=24.0,
+ ),
+ DecodedMedia(
+ type="audio",
+ path="target.wav",
+ payload=torch.linspace(-1.0, 1.0, audio_samples).unsqueeze(0),
+ sample_rate=32000,
+ ),
+ ),
+ )
+
+
+def test_t2va_declares_exact_configured_single_sample_av_contract() -> None:
+ contract = MiniMaxH3T2VAAdapter.pipeline_io_contract
+
+ assert contract is not None
+ assert contract.input_media.rules == ()
+ assert contract.negative_prompt is NegativePromptPolicy.UNSUPPORTED
+ assert contract.geometry_source is GeometrySource.CONFIGURED
+ assert contract.batch_capability is BatchCapability.SINGLE_SAMPLE
+ assert tuple(item.type for item in contract.output_media.items) == (
+ MediaType.VIDEO,
+ MediaType.AUDIO,
+ )
+ assert contract.output_media.items[0].fps is RateRequirement.REQUIRED
+ assert contract.output_media.items[1].sample_rate is RateRequirement.REQUIRED
+ MiniMaxH3T2VAAdapter.validate_offline_output_capability()
+
+
+@pytest.mark.parametrize("adapter_type", [MiniMaxH3FL2VAAdapter, MiniMaxH3Ref2VAAdapter])
+def test_conditioned_h3_workflows_require_reproducible_prefix_binder(
+ adapter_type: type,
+) -> None:
+ with pytest.raises(NotImplementedError, match="conditioned-prefix binder"):
+ adapter_type.validate_offline_output_capability()
+
+
+def test_codec_uses_framework_video_sample_without_condition_fp16_rounding() -> None:
+ adapter = _Adapter()
+ generator = torch.Generator().manual_seed(17)
+
+ encoded = MiniMaxH3AVOutputCodec(adapter).encode_output_state(
+ _media(audio_samples=100),
+ _condition(),
+ generator,
+ )
+
+ assert adapter.video_posterior.sample_calls == 1
+ assert adapter.video_posterior.sample_generator is generator
+ assert adapter.video_posterior.mode_calls == 0
+ assert adapter.audio_posterior.mode_calls == 1
+ assert adapter.audio_posterior.sample_calls == 0
+ assert adapter.vae.encoded_pixels is not None
+ assert adapter.vae.encoded_pixels.shape == (1, 3, 22, 32, 32)
+ assert adapter.audio_vae.encoded_waveform is not None
+ assert adapter.audio_vae.encoded_waveform.shape == (2, 1, 37 * 800)
+ assert encoded.clean_state.component_names == ("video", "audio")
+ assert encoded.clean_state.components["video"].shape == (1, 7, 96)
+ assert encoded.clean_state.components["audio"].shape == (1, 74, 32)
+ sampled_value = adapter.video_posterior.values.flatten()[0]
+ condition_rounded_value = sampled_value.to(torch.float16).to(torch.float32)
+ assert sampled_value != condition_rounded_value
+ assert encoded.clean_state.components["video"].flatten()[0] == sampled_value
+ assert encoded.forward_context == {}
+ assert encoded.decode_context["geometry"] == {
+ "height": 32,
+ "width": 32,
+ "num_frames": 22,
+ "num_latent_frames": 7,
+ "latent_height": 2,
+ "latent_width": 2,
+ "num_audio_latents": 37,
+ }
+ signature = encoded.geometry_signatures[0]
+ assert signature.media[0].fps == 24.0
+ assert signature.media[1].samples == 37 * 800
+ assert signature.media[1].sample_rate == 32000
+ validate_h3_encoded_output_geometry(adapter, _media(), _condition(), encoded)
+
+
+def test_geometry_hook_independently_enforces_pipeline_duration_bounds() -> None:
+ adapter = _Adapter()
+ adapter.pipeline.min_duration = 5.0
+
+ with pytest.raises(ValueError, match="outside the pipeline contract"):
+ resolve_h3_output_geometry(adapter, _condition())
+
+
+@pytest.mark.parametrize(
+ ("field", "value", "message"),
+ [
+ ("height", 64, "cached output canvas"),
+ ("num_frames", 23, "cached output frame count"),
+ ],
+)
+def test_geometry_hook_rejects_cache_from_another_training_geometry(
+ field: str,
+ value: int,
+ message: str,
+) -> None:
+ adapter = _Adapter()
+ setattr(adapter.training_args, field, value)
+
+ with pytest.raises(ValueError, match=message):
+ resolve_h3_output_geometry(adapter, _condition())
+
+
+def test_geometry_hook_rejects_invalid_input_owned_flat_layout() -> None:
+ adapter = _Adapter()
+ condition = _condition()
+ encoded = MiniMaxH3AVOutputCodec(adapter).encode_output_state(_media(), condition)
+
+ condition["audio_indices"] = condition["audio_indices"][:, :-1]
+ with pytest.raises(ValueError, match="audio layout expected 74 target rows"):
+ validate_h3_encoded_output_geometry(adapter, _media(), condition, encoded)
+
+
+def test_base_lifecycle_casts_clean_state_without_output_context_dtype_drift() -> None:
+ adapter = _base_adapter(latent_storage_dtype="fp16")
+
+ encoded = adapter.encode_output_state(_media(), _condition())
+
+ assert encoded.clean_state.components["video"].dtype is torch.float16
+ assert encoded.clean_state.components["audio"].dtype is torch.float16
+ assert encoded.forward_context == {}
+
+
+def test_adapter_decode_routes_both_components_with_cached_geometry(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ encoded = MiniMaxH3AVOutputCodec(_Adapter()).encode_output_state(_media(), _condition())
+ adapter = object.__new__(MiniMaxH3T2VAAdapter)
+ observed: dict[str, Any] = {}
+
+ def decode(self: Any, state: LatentState, **kwargs: Any) -> str:
+ observed["state"] = state
+ observed.update(kwargs)
+ return "decoded-av"
+
+ monkeypatch.setattr(MiniMaxH3T2VAAdapter, "decode_latents", decode)
+
+ assert adapter.decode_output_state(encoded, output_type="np") == "decoded-av"
+ assert observed["state"] is encoded.clean_state
+ assert observed["geometry"] == encoded.decode_context["geometry"]
+ assert observed["output_type"] == "np"
+
+
+def test_flat_offline_condition_binds_layout_and_state_native_empty_prefixes(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter = object.__new__(MiniMaxH3T2VAAdapter)
+ state = LatentState(
+ {
+ "video": torch.ones(1, 7, 96, dtype=torch.float16),
+ "audio": torch.ones(1, 74, 32, dtype=torch.float16),
+ }
+ )
+ times = ComponentTimes(
+ timestep={"video": torch.tensor([500.0]), "audio": torch.tensor([500.0])},
+ next_timestep={"video": torch.tensor([0.0]), "audio": torch.tensor([0.0])},
+ )
+ observed: dict[str, Any] = {}
+
+ def record_forward(self: Any, **kwargs: Any) -> str:
+ observed.update(kwargs)
+ return "bound"
+
+ monkeypatch.setattr(MiniMaxH3T2VAAdapter, "forward", record_forward)
+ result = adapter._forward_state(
+ batch=SimpleNamespace(),
+ state=state,
+ times=times,
+ next_state=None,
+ compute_log_prob=False,
+ return_fields=("velocity",),
+ noise_level=0.0,
+ forward_kwargs={**_condition(), "prompt_embeds": torch.zeros(1, 2, 4)},
+ )
+
+ assert result == "bound"
+ assert torch.equal(observed["layout"]["video_indices"], _condition()["video_indices"][0])
+ for component in ("video", "audio"):
+ prefix = observed["condition_prefixes"][component]
+ clean = state.components[component]
+ assert prefix.shape == (1, 0, clean.shape[-1])
+ assert prefix.dtype is clean.dtype
+ assert prefix.device == clean.device
+
+
+def test_h3_sft_path_runs_codec_cast_noising_and_velocity_only_forward(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter = _base_adapter(latent_storage_dtype="fp32")
+ condition = {**_condition(), "prompt_embeds": torch.zeros(1, 2, 4)}
+ encoded = adapter.encode_output_state(_media(), condition)
+ times, noised = build_noised_output_state(
+ adapter,
+ encoded.clean_state,
+ torch.tensor([500.0]),
+ batch=condition,
+ generator=torch.Generator().manual_seed(31),
+ )
+
+ def unexpected_scheduler_step(*args: Any, **kwargs: Any) -> None:
+ raise AssertionError("offline velocity-only forward must not step schedulers")
+
+ monkeypatch.setattr(adapter.scheduler, "step", unexpected_scheduler_step)
+ monkeypatch.setattr(adapter.audio_scheduler, "step", unexpected_scheduler_step)
+
+ output = adapter._forward_state(
+ batch=SimpleNamespace(),
+ state=noised.state,
+ times=times,
+ next_state=None,
+ compute_log_prob=False,
+ return_fields=("velocity",),
+ noise_level=0.0,
+ forward_kwargs=condition,
+ )
+ loss = flow_matching_per_sample_loss(adapter, output.velocity, noised).mean()
+ loss.backward()
+
+ transformer = adapter.get_component("transformer")
+ assert output.velocity.component_names == ("video", "audio")
+ assert output.velocity.components["video"].shape == (1, 7, 96)
+ assert output.velocity.components["audio"].shape == (1, 74, 32)
+ assert transformer.weight.grad is not None
+
+
+def test_h3_offline_dpo_arms_reuse_structured_noise_through_real_forward() -> None:
+ adapter = _base_adapter(latent_storage_dtype="fp32")
+ condition = {**_condition(), "prompt_embeds": torch.zeros(1, 2, 4)}
+ chosen = adapter.encode_output_state(_media(), condition)
+ rejected = adapter.encode_output_state(_media(), condition)
+ validate_preference_output_states(chosen, rejected)
+
+ primary_timesteps = torch.tensor([650.0])
+ chosen_times, chosen_noised = build_noised_output_state(
+ adapter,
+ chosen.clean_state,
+ primary_timesteps,
+ batch=condition,
+ generator=torch.Generator().manual_seed(41),
+ )
+ rejected_times, rejected_noised = build_noised_output_state(
+ adapter,
+ rejected.clean_state,
+ primary_timesteps,
+ batch=condition,
+ noise=chosen_noised.noise,
+ )
+ validate_preference_component_times(chosen_times, rejected_times)
+
+ outputs = [
+ adapter._forward_state(
+ batch=SimpleNamespace(),
+ state=noised.state,
+ times=times,
+ next_state=None,
+ compute_log_prob=False,
+ return_fields=("velocity",),
+ noise_level=0.0,
+ forward_kwargs=condition,
+ )
+ for times, noised in (
+ (chosen_times, chosen_noised),
+ (rejected_times, rejected_noised),
+ )
+ ]
+
+ assert rejected_noised.noise is chosen_noised.noise
+ for component in ("video", "audio"):
+ assert torch.equal(
+ rejected_noised.noise.components[component],
+ chosen_noised.noise.components[component],
+ )
+ assert outputs[0].velocity.components[component].shape == (
+ chosen_noised.state.components[component].shape
+ )
+ assert outputs[1].velocity.components[component].shape == (
+ rejected_noised.state.components[component].shape
+ )
+
+
+def test_conditioned_offline_replay_still_requires_prefix_binder() -> None:
+ adapter = object.__new__(MiniMaxH3FL2VAAdapter)
+ state = LatentState(
+ {
+ "video": torch.ones(1, 7, 96),
+ "audio": torch.ones(1, 74, 32),
+ }
+ )
+ times = ComponentTimes(
+ timestep={"video": torch.tensor([500.0]), "audio": torch.tensor([500.0])},
+ next_timestep={"video": torch.tensor([0.0]), "audio": torch.tensor([0.0])},
+ )
+
+ with pytest.raises(ValueError, match="conditioned-prefix binder"):
+ adapter._forward_state(
+ batch=SimpleNamespace(),
+ state=state,
+ times=times,
+ next_state=None,
+ compute_log_prob=False,
+ return_fields=("velocity",),
+ noise_level=0.0,
+ forward_kwargs={**_condition(), "prompt_embeds": torch.zeros(1, 2, 4)},
+ )
+
+
+def test_audio_alignment_trims_or_zero_pads_on_the_exact_latent_grid() -> None:
+ short = prepare_h3_target_audio(
+ torch.tensor([[1.0, 2.0, 3.0]]),
+ source_sample_rate=32000,
+ target_sample_rate=32000,
+ target_samples=5,
+ target_duration_seconds=5 / 32000,
+ )
+ assert short.shape == (2, 5)
+ assert torch.equal(short[0], torch.tensor([1.0, 2.0, 3.0, 0.0, 0.0]))
+ assert torch.equal(short[0], short[1])
+
+ long = prepare_h3_target_audio(
+ torch.arange(14, dtype=torch.float32).reshape(2, 7),
+ source_sample_rate=32000,
+ target_sample_rate=32000,
+ target_samples=5,
+ target_duration_seconds=5 / 32000,
+ )
+ assert torch.equal(long, torch.tensor([[0, 1, 2, 3, 4], [7, 8, 9, 10, 11]]).float())
+
+
+def test_audio_alignment_truncates_on_source_clock_before_resampling(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ observed: dict[str, Any] = {}
+
+ def convert(
+ waveform: torch.Tensor,
+ from_rate: int,
+ to_rate: int,
+ to_channels: int,
+ ) -> torch.Tensor:
+ observed["waveform"] = waveform.clone()
+ observed["rates"] = (from_rate, to_rate, to_channels)
+ return waveform.repeat_interleave(2, dim=-1).expand(2, -1).contiguous()
+
+ monkeypatch.setattr("flow_factory.models.minimax_h3._output.convert_audio", convert)
+ result = prepare_h3_target_audio(
+ torch.arange(8, dtype=torch.float32).unsqueeze(0),
+ source_sample_rate=10,
+ target_sample_rate=20,
+ target_samples=10,
+ target_duration_seconds=0.5,
+ )
+
+ assert torch.equal(observed["waveform"], torch.arange(5, dtype=torch.float32).unsqueeze(0))
+ assert observed["rates"] == (10, 20, 2)
+ assert result.shape == (2, 10)
+
+
+def test_target_video_fails_when_source_cannot_cover_configured_timeline() -> None:
+ with pytest.raises(ValueError, match="too short"):
+ prepare_h3_target_video(
+ np.zeros((4, 8, 8, 3), dtype=np.uint8),
+ source_fps=24.0,
+ target_frames=5,
+ target_fps=24.0,
+ height=8,
+ width=8,
+ )
+
+
+def test_target_video_matches_official_h3_fps_filter_for_30_to_24() -> None:
+ frames = np.zeros((10, 1, 1, 3), dtype=np.uint8)
+ frames[:, 0, 0, :] = np.arange(10, dtype=np.uint8)[:, None]
+
+ pixels = prepare_h3_target_video(
+ frames,
+ source_fps=30.0,
+ target_frames=8,
+ target_fps=24.0,
+ height=1,
+ width=1,
+ )
+
+ values = pixels[0, 0, :, 0, 0].mul(255).round().to(torch.uint8)
+ assert torch.equal(values, torch.tensor([0, 1, 3, 4, 5, 6, 8, 9], dtype=torch.uint8))
diff --git a/tests/models/minimax_h3/test_review_fixes.py b/tests/models/minimax_h3/test_review_fixes.py
index 117b76c5f..91ffd9d76 100644
--- a/tests/models/minimax_h3/test_review_fixes.py
+++ b/tests/models/minimax_h3/test_review_fixes.py
@@ -656,6 +656,8 @@ def _training_batch() -> StackedSampleBatch:
def _patch_h3_forward(monkeypatch) -> None:
def forward(*args, **kwargs):
state = args[1]
+ if kwargs.get("velocity_only"):
+ return state
return MultiModalStepOutput(
next_state=state,
next_state_mean=state,
diff --git a/tests/models/test_offline_output_capability_matrix.py b/tests/models/test_offline_output_capability_matrix.py
index 00afa9fc4..6305f3812 100644
--- a/tests/models/test_offline_output_capability_matrix.py
+++ b/tests/models/test_offline_output_capability_matrix.py
@@ -30,9 +30,8 @@
(Wan2_I2V_Adapter, "first-frame VAE condition"),
(LTX2_T2AV_Adapter, "paired video/audio decoding"),
(LTX2_I2AV_Adapter, "active mask"),
- (MiniMaxH3T2VAAdapter, "target-video posterior policy"),
- (MiniMaxH3FL2VAAdapter, "target-video posterior policy"),
- (MiniMaxH3Ref2VAAdapter, "target-video posterior policy"),
+ (MiniMaxH3FL2VAAdapter, "conditioned-prefix binder"),
+ (MiniMaxH3Ref2VAAdapter, "conditioned-prefix binder"),
],
)
def test_unimplemented_offline_media_semantics_fail_before_model_loading(
@@ -42,3 +41,8 @@ def test_unimplemented_offline_media_semantics_fail_before_model_loading(
"""Expose actionable blockers instead of silently guessing target encoding."""
with pytest.raises(NotImplementedError, match=reason_fragment):
adapter_type.validate_offline_output_capability()
+
+
+def test_minimax_h3_t2va_declares_complete_offline_output_semantics() -> None:
+ """T2VA has no conditioned prefix and can encode paired AV targets on demand."""
+ MiniMaxH3T2VAAdapter.validate_offline_output_capability()
diff --git a/tests/models/test_pipeline_contract_constructors.py b/tests/models/test_pipeline_contract_constructors.py
new file mode 100644
index 000000000..03cb140f2
--- /dev/null
+++ b/tests/models/test_pipeline_contract_constructors.py
@@ -0,0 +1,92 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Test reusable adapter pipeline-contract constructors."""
+
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaRule,
+ MediaType,
+ NegativePromptPolicy,
+ RateRequirement,
+)
+from flow_factory.models.pipeline_contracts import (
+ AUDIO_FORMAT_REQUIRED_RATE,
+ IMAGE_FORMAT,
+ VIDEO_FORMAT_OPTIONAL_FPS,
+ audio_video_output_contract,
+)
+
+
+def test_audio_video_output_contract_defaults_to_exact_required_av_sequence() -> None:
+ contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ )
+
+ assert contract.input_media.rules == ()
+ assert contract.input_media.binding is InputMediaBinding.GROUPED_BY_TYPE
+ assert contract.input_media.order is InputMediaOrder.INSENSITIVE
+ assert tuple(item.type for item in contract.output_media.items) == (
+ MediaType.VIDEO,
+ MediaType.AUDIO,
+ )
+ assert contract.output_media.items[0].fps is RateRequirement.REQUIRED
+ assert contract.output_media.items[1].sample_rate is RateRequirement.REQUIRED
+ assert contract.geometry_source is GeometrySource.OUTPUT_MEDIA
+ assert contract.batch_capability is BatchCapability.SINGLE_SAMPLE
+
+
+def test_audio_video_output_contract_preserves_grouped_image_bounds() -> None:
+ contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_rules=(InputMediaRule(format=IMAGE_FORMAT, min_count=1, max_count=2),),
+ input_order=InputMediaOrder.WITHIN_TYPE,
+ geometry_source=GeometrySource.CONFIGURED,
+ )
+
+ assert contract.input_media.rules[0].min_count == 1
+ assert contract.input_media.rules[0].max_count == 2
+ assert contract.input_media.binding is InputMediaBinding.GROUPED_BY_TYPE
+ assert contract.input_media.order is InputMediaOrder.WITHIN_TYPE
+ assert contract.geometry_source is GeometrySource.CONFIGURED
+
+
+def test_audio_video_output_contract_preserves_ordered_reference_rules() -> None:
+ contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_rules=(
+ InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=12),
+ InputMediaRule(format=VIDEO_FORMAT_OPTIONAL_FPS, min_count=0, max_count=12),
+ InputMediaRule(format=AUDIO_FORMAT_REQUIRED_RATE, min_count=0, max_count=12),
+ ),
+ input_binding=InputMediaBinding.ORDERED_REFERENCES,
+ input_order=InputMediaOrder.GLOBAL,
+ output_fps=RateRequirement.OPTIONAL,
+ output_sample_rate=RateRequirement.OPTIONAL,
+ batch_capability=BatchCapability.RAGGED,
+ )
+
+ assert tuple(rule.format.type for rule in contract.input_media.rules) == (
+ MediaType.IMAGE,
+ MediaType.VIDEO,
+ MediaType.AUDIO,
+ )
+ assert contract.input_media.binding is InputMediaBinding.ORDERED_REFERENCES
+ assert contract.input_media.order is InputMediaOrder.GLOBAL
+ assert contract.output_media.items[0].fps is RateRequirement.OPTIONAL
+ assert contract.output_media.items[1].sample_rate is RateRequirement.OPTIONAL
+ assert contract.batch_capability is BatchCapability.RAGGED
From 8d0ca84d360f815bce8f8eb8c0ba93d38c693514 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 22:58:58 +0800
Subject: [PATCH 25/76] chore: format offline branch changes
---
src/flow_factory/data_utils/dataset.py | 24 ++++++------------------
src/flow_factory/hparams/model_args.py | 4 +---
src/flow_factory/models/abc.py | 16 ++++------------
tests/docs/test_sensenova_docs.py | 5 +----
4 files changed, 12 insertions(+), 37 deletions(-)
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index 97bb2ff96..2a145cfd4 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -765,11 +765,7 @@ def _preprocess_batch(
# Complex values (nested lists/dicts) in the source JSONL must already be
# stored as JSON strings for Arrow compatibility.
batch_dict[METADATA_COLUMN] = [
- {
- k: v[idx]
- for k, v in batch.items()
- if k not in metadata_excluded_columns
- }
+ {k: v[idx] for k, v in batch.items() if k not in metadata_excluded_columns}
for idx in range(len(batch["prompt"]))
]
@@ -1357,8 +1353,7 @@ def _load_ordered_reference(
loaded["sample_rate"] = effective_sample_rate
else:
raise ValueError(
- "expected ordered reference kind in ('image', 'video', 'audio'), "
- f"got {kind!r}"
+ "expected ordered reference kind in ('image', 'video', 'audio'), " f"got {kind!r}"
)
except (FileNotFoundError, ImportError, OSError, RuntimeError, ValueError) as error:
raise ValueError(
@@ -1407,19 +1402,14 @@ def _decode_ordered_video(
if not container.streams.video:
raise ValueError(f"expected a video stream in {video_path!r}, got none")
video_stream = container.streams.video[0]
- frames = [
- frame.to_ndarray(format="rgb24")
- for frame in container.decode(video_stream)
- ]
+ frames = [frame.to_ndarray(format="rgb24") for frame in container.decode(video_stream)]
reported_frame_rate = video_stream.average_rate or video_stream.guessed_rate
frame_rate = None if reported_frame_rate is None else float(reported_frame_rate)
audio = None
sample_rate = None
if container.streams.audio:
container.seek(0)
- audio, sample_rate = _decode_av_audio_stream(
- container, container.streams.audio[0]
- )
+ audio, sample_rate = _decode_av_audio_stream(container, container.streams.audio[0])
if not frames:
raise ValueError(f"expected video frames in {video_path!r}, decoded none")
return np.stack(frames), frame_rate, audio, sample_rate
@@ -1447,12 +1437,10 @@ def _decode_av_audio_stream(container: Any, stream: Any) -> tuple[torch.Tensor,
chunks = []
for frame in container.decode(stream):
chunks.extend(
- torch.from_numpy(resampled.to_ndarray())
- for resampled in resampler.resample(frame)
+ torch.from_numpy(resampled.to_ndarray()) for resampled in resampler.resample(frame)
)
chunks.extend(
- torch.from_numpy(resampled.to_ndarray())
- for resampled in resampler.resample(None)
+ torch.from_numpy(resampled.to_ndarray()) for resampled in resampler.resample(None)
)
if not chunks:
raise ValueError("expected decoded audio samples, got none")
diff --git a/src/flow_factory/hparams/model_args.py b/src/flow_factory/hparams/model_args.py
index 45e4e5da0..0c7f61921 100644
--- a/src/flow_factory/hparams/model_args.py
+++ b/src/flow_factory/hparams/model_args.py
@@ -95,9 +95,7 @@ def _serialize_dtype_policy(value: DTypePolicy) -> Any:
"""Serialize one normalized dtype policy for YAML output."""
if isinstance(value, dict):
return {
- selector: (
- None if configured_dtype is None else str(configured_dtype).split(".")[-1]
- )
+ selector: (None if configured_dtype is None else str(configured_dtype).split(".")[-1])
for selector, configured_dtype in value.items()
}
if value is not None:
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index 41ee508d1..604e7276f 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -16,11 +16,11 @@
import hashlib
import json
import logging
-import shutil
# src/flow_factory/models/abc.py
import os
import re
+import shutil
from abc import ABC, abstractmethod
from contextlib import ExitStack, contextmanager, nullcontext
from dataclasses import asdict, dataclass, field, fields
@@ -823,20 +823,12 @@ def _load_diffusers_pipeline(
user_policy=user_policy,
manifest_policy=manifest_policy,
component_names=component_names,
- transformer_names=[
- name for name in component_names if "transformer" in name
- ],
- text_encoder_names=[
- name for name in component_names if "text_encoder" in name
- ],
+ transformer_names=[name for name in component_names if "transformer" in name],
+ text_encoder_names=[name for name in component_names if "text_encoder" in name],
preserve_unselected=True,
)
kwargs.update(
- {
- key: value
- for key, value in load_dtype_kwargs.items()
- if key not in kwargs
- }
+ {key: value for key, value in load_dtype_kwargs.items() if key not in kwargs}
)
return pipeline_class.from_pretrained(pretrained_model_name_or_path, **kwargs)
diff --git a/tests/docs/test_sensenova_docs.py b/tests/docs/test_sensenova_docs.py
index 60d6310b4..4e1bbc2b7 100644
--- a/tests/docs/test_sensenova_docs.py
+++ b/tests/docs/test_sensenova_docs.py
@@ -14,7 +14,6 @@
from pathlib import Path
-
ROOT = Path(__file__).resolve().parents[2]
@@ -25,9 +24,7 @@ def _text(path: str) -> str:
def test_main_readme_lists_sensenova_as_t2i_and_multi_reference_i2i() -> None:
readme = _text("README.md")
t2i_start = readme.index('| Text-to-Image | ')
- combined_start = readme.index(
- '
| Text-to-Image & Image(s)-to-Image | '
- )
+ combined_start = readme.index('
| Text-to-Image & Image(s)-to-Image | ')
video_start = readme.index('
| Text-to-Video | ')
t2i_only_rows = readme[t2i_start:combined_start]
From 49bb2f420436d4ac44b36b114d8c6778de497f4e Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Fri, 28 Aug 2026 23:03:55 +0800
Subject: [PATCH 26/76] fix(tests): isolate Bagel optional kernels
---
.agents/knowledge/topics/fix_patterns.md | 8 +++
src/flow_factory/trainers/offline/__init__.py | 14 ++++
tests/models/test_bagel_tdm_contracts.py | 70 +++++++++++++++----
3 files changed, 80 insertions(+), 12 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 9b6f6737b..669bc8d5d 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -198,6 +198,14 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Lesson**: An example configuration is executable documentation. Any recipe migration must update its production parse test, linked data provenance, optional dependency instructions, and validation claims in the same integration change.
- **Related Constraint**: #15
+### Optional-kernel adapter tests must lazy-load behind the dependency seam
+- **Date**: 2026-08-28
+- **Symptom**: Collecting the Bagel TDM contract test on macOS failed before any test ran because `flash-attn>=2.5.8` was unavailable.
+- **Root Cause**: The test imported the Bagel adapter at module scope instead of installing the existing fake optional-kernel modules before the adapter import.
+- **Fix**: `tests/models/test_bagel_tdm_contracts.py` now lazily imports Bagel after stubbing `flash_attn`, OpenCV, and the availability probes; new Python files also receive the required Apache 2.0 headers.
+- **Lesson**: Contract tests for CUDA-only optional adapters must exercise the adapter through its dependency boundary so CPU and macOS collection remains valid; importing such adapters at module scope turns an optional dependency into a repository-wide test dependency.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/trainers/offline/__init__.py b/src/flow_factory/trainers/offline/__init__.py
index 13c2d58e4..c47814e81 100644
--- a/src/flow_factory/trainers/offline/__init__.py
+++ b/src/flow_factory/trainers/offline/__init__.py
@@ -1,3 +1,17 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
"""Finite-dataset training algorithms."""
from .offline_dpo import OfflineDPOTrainer
diff --git a/tests/models/test_bagel_tdm_contracts.py b/tests/models/test_bagel_tdm_contracts.py
index 744084fcf..949359ea5 100644
--- a/tests/models/test_bagel_tdm_contracts.py
+++ b/tests/models/test_bagel_tdm_contracts.py
@@ -1,10 +1,30 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import importlib
+import importlib.machinery
+import sys
+import types
from types import MethodType
from typing import Any
import pytest
import torch
-from flow_factory.models.bagel.bagel import BagelAdapter, BagelSample
+import flow_factory.utils.imports as import_utils
from flow_factory.samples import BaseSample
from flow_factory.trainers.distillation.distillation_runtime import (
validate_media_free_rollout,
@@ -12,8 +32,24 @@
)
-def _adapter(decoder: Any) -> BagelAdapter:
- adapter = object.__new__(BagelAdapter)
+def _load_bagel_types(monkeypatch: pytest.MonkeyPatch) -> tuple[type, type]:
+ """Load Bagel behind the same optional-kernel seam as its adapter tests."""
+ flash_attn = types.ModuleType("flash_attn")
+ flash_attn.__spec__ = importlib.machinery.ModuleSpec("flash_attn", loader=None)
+ flash_attn.flash_attn_varlen_func = lambda *args, **kwargs: None
+ cv2 = types.ModuleType("cv2")
+ cv2.__spec__ = importlib.machinery.ModuleSpec("cv2", loader=None)
+ monkeypatch.setitem(sys.modules, "flash_attn", flash_attn)
+ monkeypatch.setitem(sys.modules, "cv2", cv2)
+ monkeypatch.setattr(import_utils, "is_flash_attn_available", lambda *args: True)
+ monkeypatch.setattr(import_utils, "get_flash_attn_version", lambda: "test")
+
+ module = importlib.import_module("flow_factory.models.bagel.bagel")
+ return module.BagelAdapter, module.BagelSample
+
+
+def _adapter(adapter_type: type, decoder: Any) -> Any:
+ adapter = object.__new__(adapter_type)
adapter.decode_latents = MethodType(decoder, adapter)
return adapter
@@ -33,18 +69,21 @@ def _result(batch_size: int = 2) -> dict[str, Any]:
}
-def test_bagel_assembles_samples_from_one_batched_decode() -> None:
+def test_bagel_assembles_samples_from_one_batched_decode(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_type, sample_type = _load_bagel_types(monkeypatch)
calls: list[tuple[tuple[int, ...], tuple[int, int] | None]] = []
def decode(
- _self: BagelAdapter,
+ _self: Any,
latents: torch.Tensor,
image_shape: tuple[int, int] | None = None,
) -> list[torch.Tensor]:
calls.append((tuple(latents.shape), image_shape))
return [torch.full((3, 8, 8), index) for index in range(latents.shape[0])]
- adapter = _adapter(decode)
+ adapter = _adapter(adapter_type, decode)
samples = adapter._assemble_samples(
_result(),
prompts=["first", "second"],
@@ -54,21 +93,25 @@ def decode(
)
assert calls == [((2, 4, 8), (64, 64))]
- assert all(isinstance(sample, BagelSample) for sample in samples)
+ assert all(isinstance(sample, sample_type) for sample in samples)
assert torch.equal(samples[0].image, torch.zeros(3, 8, 8))
assert torch.equal(samples[1].image, torch.ones(3, 8, 8))
-def test_bagel_media_free_samples_keep_replay_trajectory() -> None:
+def test_bagel_media_free_samples_keep_replay_trajectory(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_type, _ = _load_bagel_types(monkeypatch)
+
def decode(
- _self: BagelAdapter,
+ _self: Any,
latents: torch.Tensor,
image_shape: tuple[int, int] | None = None,
) -> list[torch.Tensor]:
del latents, image_shape
raise AssertionError("TDM must not invoke the real Bagel decoder")
- adapter = _adapter(decode)
+ adapter = _adapter(adapter_type, decode)
validate_media_free_rollout(adapter, algorithm_name="TDM")
with without_media_decoding(adapter, algorithm_name="TDM"):
@@ -90,7 +133,10 @@ def decode(
adapter.decode_latents(torch.zeros(2, 4, 8), image_shape=(64, 64))
-def test_bagel_maps_reference_guidance_to_text_cfg() -> None:
- adapter = object.__new__(BagelAdapter)
+def test_bagel_maps_reference_guidance_to_text_cfg(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_type, _ = _load_bagel_types(monkeypatch)
+ adapter = object.__new__(adapter_type)
assert adapter.reference_guidance_kwargs(4.0) == {"cfg_text_scale": 4.0}
From 1622f5ef2aa350bbe797319cd4c40e95de47ce2d Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sat, 29 Aug 2026 11:12:15 +0800
Subject: [PATCH 27/76] fix(checkpoint): align distillation resume cursor with
rollout GAS
---
.agents/knowledge/topics/fix_patterns.md | 8 +++++
.../distillation/distillation_runtime.py | 31 ++++++++++---------
tests/trainers/test_distillation_metrics.py | 28 ++++++++++++-----
3 files changed, 44 insertions(+), 23 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 669bc8d5d..9aeed8b3b 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -206,6 +206,14 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Lesson**: Contract tests for CUDA-only optional adapters must exercise the adapter through its dependency boundary so CPU and macOS collection remains valid; importing such adapters at module scope turns an optional dependency into a repository-wide test dependency.
- **Related Constraint**: N/A
+### Distillation exact cursors count rollout batches, not backend work items
+- **Date**: 2026-08-29
+- **Symptom**: After timestep-aligned TDM accumulation made each trajectory boundary one backend work item, exact resume skipped `num_inference_steps` times too many prompt batches.
+- **Root Cause**: Cursor reconstruction still multiplied completed rollout iterations by backend `gradient_accumulation_steps`, even though one rollout now contributes multiple boundary losses to that accumulation window.
+- **Fix**: `trainers/distillation/distillation_runtime.py` now derives consumed prompt batches through `resolve_rollout_accumulation_steps()`, and the cursor regression locks `gradient_accumulation_steps=8`, four losses per rollout, and two completed iterations to four consumed batches.
+- **Lesson**: Persisted acquisition progress must be projected through the current acquisition-to-backend work-item ratio. Backend GAS is not a valid dataloader cursor when one acquired batch expands into multiple backward graphs.
+- **Related Constraint**: #18a
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/trainers/distillation/distillation_runtime.py b/src/flow_factory/trainers/distillation/distillation_runtime.py
index daab3ab81..15d8217ac 100644
--- a/src/flow_factory/trainers/distillation/distillation_runtime.py
+++ b/src/flow_factory/trainers/distillation/distillation_runtime.py
@@ -488,7 +488,6 @@ def _completed_rollout_batch_count(trainer: Any) -> int:
progress = getattr(trainer, "progress", None)
completed_iterations = getattr(progress, "rollout_iteration", 0)
training_args = getattr(trainer, "training_args", None)
- accumulation_steps = getattr(training_args, "gradient_accumulation_steps", 1)
if (
not isinstance(completed_iterations, int)
or isinstance(completed_iterations, bool)
@@ -498,16 +497,8 @@ def _completed_rollout_batch_count(trainer: Any) -> int:
"expected rollout_iteration >= 0 as an int, received "
f"{type(completed_iterations).__name__}: {completed_iterations!r}"
)
- if (
- not isinstance(accumulation_steps, int)
- or isinstance(accumulation_steps, bool)
- or accumulation_steps < 1
- ):
- raise ValueError(
- "expected gradient_accumulation_steps >= 1 as an int, received "
- f"{type(accumulation_steps).__name__}: {accumulation_steps!r}"
- )
- return completed_iterations * accumulation_steps
+ rollout_accumulation_steps = resolve_rollout_accumulation_steps(training_args)
+ return completed_iterations * rollout_accumulation_steps
def _collect_rollout_loader_generators(dataloader: Any) -> List[torch.Generator]:
@@ -593,10 +584,11 @@ def _restore_rollout_data_cursor(
"""Rebuild one deterministic loader iterator at a global batch boundary.
Exact checkpoints are published only between acquisition cycles. Each completed
- distillation cycle consumes exactly ``gradient_accumulation_steps`` batches, so
- the persisted rollout-iteration counter is the authoritative cursor. Rebuilding
- from it avoids serializing a Python iterator and works for both finite
- multi-source loaders and the framework's infinite grouped batch samplers.
+ distillation cycle consumes ``gradient_accumulation_steps`` divided by its
+ per-rollout loss count, so the persisted rollout-iteration counter is the
+ authoritative cursor. Rebuilding from it avoids serializing a Python iterator
+ and works for both finite multi-source loaders and the framework's infinite
+ grouped batch samplers.
"""
if (
not isinstance(consumed_batches, int)
@@ -854,6 +846,15 @@ def resolve_rollout_accumulation_steps(training_args: Any) -> int:
"""Recover rollout batches from timestep-aligned backend GAS."""
accumulation_steps = training_args.gradient_accumulation_steps
losses_per_rollout = training_args.get_num_train_timesteps(None)
+ if (
+ not isinstance(accumulation_steps, int)
+ or isinstance(accumulation_steps, bool)
+ or accumulation_steps < 1
+ ):
+ raise ValueError(
+ "expected gradient_accumulation_steps >= 1 as an int, received "
+ f"{type(accumulation_steps).__name__}: {accumulation_steps!r}"
+ )
if (
not isinstance(losses_per_rollout, int)
or isinstance(losses_per_rollout, bool)
diff --git a/tests/trainers/test_distillation_metrics.py b/tests/trainers/test_distillation_metrics.py
index 57bfcf07f..1038ffce2 100644
--- a/tests/trainers/test_distillation_metrics.py
+++ b/tests/trainers/test_distillation_metrics.py
@@ -128,13 +128,19 @@ def __getitem__(self, index: int) -> Dict[str, torch.Tensor]:
return {"value": torch.tensor(self.values[index])}
-def _rollout_cursor_trainer(progress: TrainingProgress) -> SimpleNamespace:
+def _rollout_cursor_trainer(
+ progress: TrainingProgress,
+ *,
+ accumulation_steps: int = 2,
+ losses_per_rollout: int = 1,
+) -> SimpleNamespace:
"""Build the one-batch rollout surface around an infinite grouped loader."""
return SimpleNamespace(
progress=progress,
training_args=SimpleNamespace(
- gradient_accumulation_steps=2,
+ gradient_accumulation_steps=accumulation_steps,
num_batches_per_epoch=3,
+ get_num_train_timesteps=lambda config: losses_per_rollout,
),
dataloader=_InfiniteGroupedLoader(),
adapter=SimpleNamespace(rollout=lambda: None),
@@ -185,6 +191,7 @@ def sample_batch(batch: Dict[str, torch.Tensor], **kwargs: Any) -> list[tuple]:
training_args=SimpleNamespace(
gradient_accumulation_steps=2,
num_batches_per_epoch=3,
+ get_num_train_timesteps=lambda config: 1,
),
dataloader=dataloader,
adapter=SimpleNamespace(rollout=lambda: None),
@@ -256,6 +263,7 @@ def sample_batch(batch: Dict[str, Any], **kwargs: Any) -> list[tuple[str, int]]:
# independently round their quotas. The finite wrapper length is the
# authoritative result when those two values differ.
num_batches_per_epoch=2,
+ get_num_train_timesteps=lambda config: 1,
),
dataloader=_finite_multi_source_loader(),
adapter=SimpleNamespace(rollout=lambda: None),
@@ -614,9 +622,13 @@ def test_exact_resume_reconstructs_infinite_grouped_rollout_cursor() -> None:
assert resumed.dataloader.batch_sampler.set_epoch_calls == [1]
-def test_exact_resume_uses_gas_to_reconstruct_rollout_batch_count() -> None:
- """Completed rollout iterations expand to the exact number of consumed batches."""
- resumed = _rollout_cursor_trainer(TrainingProgress(rollout_iteration=4))
+def test_exact_resume_uses_rollout_factor_to_reconstruct_batch_count() -> None:
+ """Boundary-aligned GAS must not make exact resume skip extra prompt batches."""
+ resumed = _rollout_cursor_trainer(
+ TrainingProgress(rollout_iteration=2),
+ accumulation_steps=8,
+ losses_per_rollout=4,
+ )
next_batch = generate_one_rollout_batch(
resumed,
@@ -624,9 +636,9 @@ def test_exact_resume_uses_gas_to_reconstruct_rollout_batch_count() -> None:
algorithm_name="TDM",
)
- assert next_batch == [(2, 2)]
- assert resumed._rollout_batches_consumed == 9
- assert resumed.dataloader.batch_sampler.set_epoch_calls == [2]
+ assert next_batch == [(1, 1)]
+ assert resumed._rollout_batches_consumed == 5
+ assert resumed.dataloader.batch_sampler.set_epoch_calls == [1]
@pytest.mark.parametrize("use_explicit_generator", [False, True])
From ea3c1329debd2b91f2bdd8d70fac1370d19ca092 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sat, 29 Aug 2026 11:13:39 +0800
Subject: [PATCH 28/76] chore(checkpointing): add required license headers
---
src/flow_factory/hparams/gradient_checkpointing.py | 14 ++++++++++++++
src/flow_factory/models/checkpointing.py | 14 ++++++++++++++
tests/hparams/test_gradient_checkpointing.py | 14 ++++++++++++++
.../test_selective_gradient_checkpointing.py | 14 ++++++++++++++
4 files changed, 56 insertions(+)
diff --git a/src/flow_factory/hparams/gradient_checkpointing.py b/src/flow_factory/hparams/gradient_checkpointing.py
index e5c476e01..8b21eed19 100644
--- a/src/flow_factory/hparams/gradient_checkpointing.py
+++ b/src/flow_factory/hparams/gradient_checkpointing.py
@@ -1,3 +1,17 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
"""Gradient-checkpointing policy configuration."""
from __future__ import annotations
diff --git a/src/flow_factory/models/checkpointing.py b/src/flow_factory/models/checkpointing.py
index 84a9803ec..6a68475d2 100644
--- a/src/flow_factory/models/checkpointing.py
+++ b/src/flow_factory/models/checkpointing.py
@@ -1,3 +1,17 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
"""Model-level selective gradient-checkpointing utilities."""
from __future__ import annotations
diff --git a/tests/hparams/test_gradient_checkpointing.py b/tests/hparams/test_gradient_checkpointing.py
index 132d95b34..c80f066da 100644
--- a/tests/hparams/test_gradient_checkpointing.py
+++ b/tests/hparams/test_gradient_checkpointing.py
@@ -1,3 +1,17 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
import pytest
from flow_factory.hparams.gradient_checkpointing import (
diff --git a/tests/models/test_selective_gradient_checkpointing.py b/tests/models/test_selective_gradient_checkpointing.py
index 7626da9c4..2af4ba126 100644
--- a/tests/models/test_selective_gradient_checkpointing.py
+++ b/tests/models/test_selective_gradient_checkpointing.py
@@ -1,3 +1,17 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
from types import SimpleNamespace
import pytest
From bcc44677443bf20ba12ac10897d12f5fe8d90246 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sat, 29 Aug 2026 11:15:44 +0800
Subject: [PATCH 29/76] fix(tests): align DMD2 fixture with rollout contract
---
tests/trainers/test_dmd2.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/trainers/test_dmd2.py b/tests/trainers/test_dmd2.py
index 5e698bc9b..f37769760 100644
--- a/tests/trainers/test_dmd2.py
+++ b/tests/trainers/test_dmd2.py
@@ -48,6 +48,7 @@ def _trainer(**training_overrides: Any) -> DMD2Trainer:
"num_inference_steps": 1,
"per_device_batch_size": 1,
"gradient_accumulation_steps": 1,
+ "get_num_train_timesteps": lambda _config: 1,
"ttur_fake_updates": 5,
"num_inner_epochs": 1,
}
From a5d84e63a7907c58aa1e0ff09b52232a83232142 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sat, 29 Aug 2026 11:24:00 +0800
Subject: [PATCH 30/76] chore: normalize imports after parent rebase
---
src/flow_factory/hparams/training_args/__init__.py | 8 ++++----
src/flow_factory/trainers/distillation/tdm.py | 2 +-
src/flow_factory/trainers/distillation/tdm_r1.py | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/flow_factory/hparams/training_args/__init__.py b/src/flow_factory/hparams/training_args/__init__.py
index cec2f6670..b0955622c 100644
--- a/src/flow_factory/hparams/training_args/__init__.py
+++ b/src/flow_factory/hparams/training_args/__init__.py
@@ -21,6 +21,10 @@
from flow_factory.hparams.training_args import get_training_args_class
"""
+from ..gradient_checkpointing import (
+ GradientCheckpointingPolicy,
+ GradientCheckpointingSpec,
+)
from ._base import EvaluationArguments, TrainingArguments
from ._registry import get_training_args_class, list_registered_training_args
from .awm import AWMTrainingArguments
@@ -36,10 +40,6 @@
from .sft import SFTTrainingArguments
from .tdm import TDMTrainingArguments
from .tdm_r1 import TDMR1TrainingArguments
-from ..gradient_checkpointing import (
- GradientCheckpointingPolicy,
- GradientCheckpointingSpec,
-)
__all__ = [
"EvaluationArguments",
diff --git a/src/flow_factory/trainers/distillation/tdm.py b/src/flow_factory/trainers/distillation/tdm.py
index 5c863d186..0abc6a26b 100644
--- a/src/flow_factory/trainers/distillation/tdm.py
+++ b/src/flow_factory/trainers/distillation/tdm.py
@@ -49,8 +49,8 @@
reference_forward_kwargs,
reject_training_rewards,
replay_forward_kwargs,
- resolve_rollout_accumulation_steps,
require_velocity,
+ resolve_rollout_accumulation_steps,
role_repeat_progress,
run_distillation_training_step,
run_role_phase,
diff --git a/src/flow_factory/trainers/distillation/tdm_r1.py b/src/flow_factory/trainers/distillation/tdm_r1.py
index c00fb8497..554d809cf 100644
--- a/src/flow_factory/trainers/distillation/tdm_r1.py
+++ b/src/flow_factory/trainers/distillation/tdm_r1.py
@@ -34,8 +34,8 @@
generate_one_rollout_batch,
query_score_velocity,
record_distillation_metric,
- resolve_rollout_accumulation_steps,
require_velocity,
+ resolve_rollout_accumulation_steps,
role_repeat_progress,
run_role_phase,
)
From 9857aa71b3ecfe7d2bcc42e358c8d557d9f8f535 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sat, 29 Aug 2026 14:35:44 +0800
Subject: [PATCH 31/76] feat(offline): support Wan LTX2 and MiniMax H3
workflows
---
.agents/knowledge/architecture.md | 25 +-
.agents/knowledge/dependencies.md | 7 +-
.../knowledge/topics/adapter_conventions.md | 27 +-
.agents/knowledge/topics/fix_patterns.md | 103 ++
.agents/knowledge/topics/minimax_h3.md | 51 +-
.../topics/train_inference_consistency.md | 9 +-
README.md | 38 +-
docker/README.md | 7 +-
docker/docker-cuda/Dockerfile | 5 +-
examples/README.md | 6 +-
guidance/datasets.md | 72 +-
guidance/gpu_validation.md | 240 ++++
guidance/new_model.md | 50 +-
guidance/workflow.md | 34 +-
src/flow_factory/contracts/__init__.py | 2 +
src/flow_factory/contracts/pipeline_io.py | 259 +++-
src/flow_factory/data_utils/dataset.py | 53 +-
.../data_utils/offline_condition_cache.py | 188 ++-
.../data_utils/offline_dataset.py | 1 +
.../data_utils/offline_train_data.py | 13 +-
src/flow_factory/data_utils/schema.py | 23 +
src/flow_factory/models/__init__.py | 9 +
src/flow_factory/models/abc.py | 202 +++-
src/flow_factory/models/condition_state.py | 203 ++++
.../models/configured_image_output.py | 2 +-
src/flow_factory/models/ltx2/_output.py | 1066 +++++++++++++++++
src/flow_factory/models/ltx2/ltx2_i2av.py | 124 +-
src/flow_factory/models/ltx2/ltx2_t2av.py | 105 +-
.../models/minimax_h3/__init__.py | 8 +-
.../models/minimax_h3/_condition.py | 99 ++
src/flow_factory/models/minimax_h3/_output.py | 92 +-
.../models/minimax_h3/adapters.py | 151 ++-
src/flow_factory/models/minimax_h3/blocks.py | 252 +++-
.../models/minimax_h3/workflow.py | 81 +-
src/flow_factory/models/pipeline_contracts.py | 20 +-
src/flow_factory/models/wan/_conditioning.py | 527 ++++++++
src/flow_factory/models/wan/_output.py | 208 +++-
src/flow_factory/models/wan/wan2_i2v.py | 421 ++++---
src/flow_factory/models/wan/wan2_t2v.py | 164 +--
src/flow_factory/trainers/abc.py | 1 +
.../trainers/common/flow_matching.py | 10 +-
.../trainers/common/offline_batch.py | 31 +-
.../trainers/common/runtime_identity.py | 6 +
.../trainers/offline/offline_dpo.py | 19 +-
src/flow_factory/trainers/offline/sft.py | 12 +-
tests/contracts/test_pipeline_io_contract.py | 249 +++-
.../test_offline_condition_cache.py | 295 +++++
tests/data_utils/test_offline_train_data.py | 7 +-
tests/data_utils/test_schema.py | 25 +
tests/docs/test_minimax_h3_docs.py | 41 +-
tests/models/ltx2/test_ltx2_adapter_init.py | 29 +-
.../models/ltx2/test_ltx2_component_hooks.py | 29 +
tests/models/ltx2/test_ltx2_output_codec.py | 526 ++++++++
.../models/minimax_h3/test_condition_state.py | 116 ++
.../minimax_h3/test_diffusers_api_contract.py | 154 ++-
tests/models/minimax_h3/test_modular_core.py | 51 +-
tests/models/minimax_h3/test_output_codec.py | 234 +++-
.../minimax_h3/test_workflow_execution.py | 91 +-
.../test_offline_output_capability_matrix.py | 37 +-
.../test_output_state_adapter_lifecycle.py | 126 +-
tests/models/test_wan_output_codec.py | 351 +++++-
.../test_base_adapter_trajectory.py | 43 +
.../trainers/test_offline_batch_primitives.py | 26 +
tests/trainers/test_offline_flow_matching.py | 2 +-
tests/trainers/test_offline_trainers.py | 31 +-
tests/trainers/test_runtime_identity.py | 17 +
66 files changed, 6771 insertions(+), 735 deletions(-)
create mode 100644 guidance/gpu_validation.md
create mode 100644 src/flow_factory/models/condition_state.py
create mode 100644 src/flow_factory/models/ltx2/_output.py
create mode 100644 src/flow_factory/models/minimax_h3/_condition.py
create mode 100644 src/flow_factory/models/wan/_conditioning.py
create mode 100644 tests/models/ltx2/test_ltx2_output_codec.py
create mode 100644 tests/models/minimax_h3/test_condition_state.py
diff --git a/.agents/knowledge/architecture.md b/.agents/knowledge/architecture.md
index 8496d9f7c..84baee949 100644
--- a/.agents/knowledge/architecture.md
+++ b/.agents/knowledge/architecture.md
@@ -61,7 +61,8 @@ exhaustion; offline output media is decoded and encoded on the fly, while only p
conditions enter the preprocessing cache.
Exact runtime identity is built from realized prepared state. It locks optimizer/model/backend
-semantics, ordered training data, and the complete replayed evaluation path (cadence, arguments,
+semantics, the checkpoint-realized pipeline I/O contract, ordered training data, and the complete
+replayed evaluation path (cadence, arguments,
per-dataset overrides, rewards, and ordered prepared loaders). Logging, checkpoint cadence, run
budget, and resume location remain operational. Exact-state save fails before mutation on MPS
because Accelerate does not persist the device RNG needed for exact continuation.
@@ -89,6 +90,9 @@ All four registries map string keys → lazy import paths. Resolution: registry
| `awm` | `AWMTrainer` | Decoupled | `BaseTrainer` |
| `crd` | `CRDTrainer` | Decoupled | `BaseTrainer` |
| `diffusion-opd` | `DiffusionOPDTrainer` | Distillation (on-policy) | `BaseTrainer` |
+| `dmd2` | `DMD2Trainer` | Distillation | `BaseTrainer` |
+| `tdm` | `TDMTrainer` | Distillation | `BaseTrainer` |
+| `tdm-r1` | `TDMR1Trainer` | Distillation + reward | `BaseTrainer` |
**Flat hierarchy**: New trainers inherit from `BaseTrainer` directly. The sanctioned exceptions are `GRPOGuardTrainer → GRPOTrainer` and `DPPOTrainer → GRPOTrainer` (strict GRPO loss variants; see constraint #11).
@@ -107,6 +111,9 @@ All four registries map string keys → lazy import paths. Resolution: registry
| `wan2_i2v` | `Wan2_I2V_Adapter` | Image-to-Video |
| `ltx2_t2av` | `LTX2_T2AV_Adapter` | Text-to-Audio-Video |
| `ltx2_i2av` | `LTX2_I2AV_Adapter` | Image-to-Audio-Video |
+| `minimax-h3-t2va` | `MiniMaxH3T2VAAdapter` | Text-to-Video-Audio |
+| `minimax-h3-fl2va` | `MiniMaxH3FL2VAAdapter` | Sparse First/Last-Frame-to-Video-Audio |
+| `minimax-h3-ref2va` | `MiniMaxH3Ref2VAAdapter` | Ordered-Reference-to-Video-Audio |
| `bagel` | `BagelAdapter` | Text-to-Image & Image(s)-to-Image (T2I & I2I both batched via NaViT packing; subset-round packing handles variable I2I reference-image count, no per-sample fallback — see `topics/adapter_conventions.md`) |
| `sensenova` | `SenseNovaAdapter` | Text-to-Image & Image(s)-to-Image (SenseNova-U1 1.0/1.5; ordered variable-count references remain grouped in `images` and preserve within-type order; independent samples use B=1 prefixes rather than Bagel-style NaViT packing) |
@@ -157,15 +164,25 @@ Timesteps are `[0, 1000]` (scheduler scale); sigmas are `[0, 1]` (flow-matching
Each model adapter wraps a diffusers pipeline into the `BaseAdapter` interface:
- `preprocess_func()` — prompt/input-condition preprocessing and cache projection
- `pipeline_io_contract` — model-neutral input/output modality and geometry declaration
+- `effective_pipeline_io_contract` — checkpoint-realized specialization of the class contract
+- `prepare_condition_state()` — one validated input-owned runtime realization reused across
+ candidate encoding and model forwards
- `encode_output_state()` — validated on-the-fly offline target encoding through an optional codec
- `inference()` — full denoising loop (Stage 3)
- `forward()` — single-step denoising (Stage 6)
**Per-modality encoders** (`encode_prompt`, `encode_image`, `encode_video`, `encode_audio`) are no-op by default on `BaseAdapter` — override only the modalities your model consumes. `preprocess_func` dispatches to all four and skips any that return `None`, so text/image/video-only adapters need no stub overrides for unused modalities.
-Offline codecs declare logical required components without materializing them. Condition/output
-encoders share role-neutral transforms where possible, while callers retain explicit official
-posterior `sample` versus `argmax` semantics.
+Offline condition preparers and codecs declare logical required components without materializing
+them. Condition/output encoders share role-neutral transforms where possible, while callers retain
+explicit official posterior `sample` versus `argmax` semantics. Candidate-specific output context
+cannot overwrite cached or prepared input fields. A separate flow-matching objective reducer lets
+multi-modal SFT/DPO specialize loss aggregation without changing online trajectory reductions.
+
+Input contracts may declare semantic media slots and aggregate cross-type cardinality rules. In
+strict V2 data, an explicit input-only `slot` reserves its argument; unslotted media fills remaining
+slots in declaration order. Outputs reject slots. This keeps algorithm data model-neutral while the
+adapter owns bindings such as first/last frame and ordered heterogeneous references.
**Flat hierarchy**: All adapters inherit directly from `BaseAdapter` — never from another adapter (see constraint #12). Shared logic within a model family uses helper functions, code duplication, or mixins — not adapter subclassing.
diff --git a/.agents/knowledge/dependencies.md b/.agents/knowledge/dependencies.md
index c3e2e41ac..d6d52ef18 100644
--- a/.agents/knowledge/dependencies.md
+++ b/.agents/knowledge/dependencies.md
@@ -42,7 +42,7 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
| `torchvision` | >= 0.19.0 | Vision utilities |
| `torchaudio` | >= 2.4.0 | Audio I/O (audio / audio-video models, CLAP) |
| `transformers` | >= 4.57.1 | Text encoders, tokenizers |
-| `diffusers` | >= 0.37.0 | Diffusion pipelines, schedulers |
+| `diffusers` | >= 0.40.0 | Diffusion pipelines, schedulers, MiniMax H3 and LTX2 APIs |
| `accelerate` | >= 1.11.0 | Distributed training, mixed precision |
| `peft` | >= 0.17.0 | LoRA, parameter-efficient fine-tuning |
| `datasets` | >= 3.3.2 | Dataset loading |
@@ -57,6 +57,8 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
- DeepSpeed is optional — Accelerate alone handles most distributed scenarios.
### diffusers
+- Use the released `diffusers>=0.40.0` package as the authoritative API. The repository submodule
+ may be used for upstream development, but must not silently override the declared runtime dependency.
- Model adapters depend on specific pipeline classes from diffusers. Major version bumps may rename or remove pipeline classes.
- `load_pipeline()` in each adapter returns a `DiffusionPipeline`-compatible object; breaking changes in diffusers' pipeline API require adapter updates.
@@ -75,7 +77,8 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
### accelerate
- Primary distributed backend. `accelerator.prepare()` wraps a single `ModelBundle` (all target components) plus the optimizer as one root (constraint #9).
-- The dataloader uses custom samplers and is NOT prepared via accelerate.
+- Online generation uses framework samplers. Finite SFT/offline-DPO data uses PyTorch's official
+ `DistributedSampler`; that already-sharded loader is not prepared via Accelerate.
### peft
- Provides LoRA functionality. Applied via `BaseAdapter.apply_lora()` to components listed in `target_components`.
diff --git a/.agents/knowledge/topics/adapter_conventions.md b/.agents/knowledge/topics/adapter_conventions.md
index 8f2ce5dd0..595e281d5 100644
--- a/.agents/knowledge/topics/adapter_conventions.md
+++ b/.agents/knowledge/topics/adapter_conventions.md
@@ -44,18 +44,18 @@ the complete mapping when its forward has different semantics or additional guid
condition `guidance_scale=3.5`; this is a learned model embedding, not classifier-free guidance.
- The currently supported Flux2-Klein forward always passes `guidance=None` into its transformer,
so its `guidance_scale` remains conventional two-pass CFG and inherits the neutral `1.0`.
-- Wan T2V neutralizes both transformer stages with `guidance_scale=guidance_scale_2=1.0`.
+- Wan T2V/I2V neutralize both transformer stages with
+ `guidance_scale=guidance_scale_2=1.0`.
- SenseNova neutralizes text and image guidance together and disables CFG normalization.
- Bagel replaces the base mapping with its actual `cfg_text_scale` / `cfg_img_scale` arguments;
it must not inherit an irrelevant `guidance_scale` key through its permissive `**kwargs`.
-- MiniMax H3 T2VA inherits neutral `guidance_scale=1.0`; its strict forward validates that
- interface value even though the guidance-distilled checkpoint has no CFG branch.
+- MiniMax H3 T2VA/FL2VA/Ref2VA inherit neutral `guidance_scale=1.0`; their strict forward validates
+ that interface value even though the guidance-distilled checkpoint has no CFG branch.
+- LTX2 sets video/audio CFG scales and modality scales to `1.0`, CFG rescale and STG scales to
+ `0.0`, and neutralizes STG block selection with
+ `spatio_temporal_guidance_blocks=None`.
-Wan I2V and LTX2 remain behind explicit offline output-codec blockers. Wan I2V must mirror the two
-neutral Wan transformer scales before it is enabled. LTX2 must set video/audio CFG scales and
-modality scales to `1.0`, CFG rescale and STG scales to `0.0`, and neutralize its STG block
-selection with `spatio_temporal_guidance_blocks=None`. The mapping is adapter-owned model
-conditioning, never a sampling or algorithm knob.
+These mappings are adapter-owned model conditioning, never sampling or algorithm knobs.
### Models with model-specific CFG extensions
@@ -210,6 +210,17 @@ LTX2 packs `[video|audio]` into one `(B, Seq, C)` sequence, so it resolves as PA
14. **Offline forward overrides are model conditioning, not sampling CFG** — SFT and offline DPO expand the adapter's complete immutable `offline_training_forward_overrides` mapping into every policy and reference forward, after batch conditions and configured sampling arguments. Conventional CFG adapters use their CFG-off point; guidance-distilled adapters use the value expected by their learned guidance embedder; multi-branch adapters neutralize every active branch under its real forward argument names. Replace the base mapping rather than adding unrelated keys, and never infer it from cached negative embeddings or expose it as an algorithm knob.
+15. **Runtime condition realization has one input owner** — A conditioned offline adapter declares
+ `build_condition_state_preparer()` only when cached fields are not already the exact forward
+ condition. `prepare_condition_state()` runs once per batch. SFT reuses that realization for
+ target binding; offline DPO reuses the same tensor leaves for chosen/rejected and
+ policy/reference forwards. The input-owned `forward_context` and `output_context` may share
+ prepared tensors. Require collision-free keys only within each consumer's merged view: cached
+ plus forward fields, and input-owned plus candidate-owned output fields. The preparer declaration
+ lists logical components but must not materialize, move, replace, or cast them. Checkpoint
+ variants narrow the class-level I/O superset through `_resolve_pipeline_io_contract()` rather
+ than branching inside the dataset or algorithm.
+
## Fix Records
### Sampling CFG leaked into finite-data velocity matching
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 9aeed8b3b..401c79356 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -214,6 +214,109 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Lesson**: Persisted acquisition progress must be projected through the current acquisition-to-backend work-item ratio. Backend GAS is not a valid dataloader cursor when one acquired batch expands into multiple backward graphs.
- **Related Constraint**: #18a
+### Sparse media arguments require semantic input slots
+- **Date**: 2026-08-29
+- **Symptom**: A last-frame-only MiniMax H3 record could not be represented without pretending its
+ image was the first frame, and heterogeneous Ref2VA cardinality rules could not be expressed by
+ independent per-type counts.
+- **Root Cause**: The public offline schema and pipeline contract treated media position and
+ per-modality cardinality as the complete binding model.
+- **Fix**: V2 input media now accepts an input-only semantic `slot`; contracts declare ordered and
+ required slots plus aggregate count/required-any-type rules; projection resolves explicit slots
+ first and fills remaining slots positionally. Outputs reject slots. Construction rejects
+ multi-slot rules that claim order-insensitivity and aggregate bounds that cannot satisfy their
+ per-type rules.
+- **Lesson**: Use generic argument-binding metadata for sparse conditions and keep model-specific
+ argument names in adapter contracts, not algorithm code or ad-hoc dataset columns.
+- **Related Constraint**: #5
+
+### Offline velocity objectives must bypass unused scheduler transitions
+- **Date**: 2026-08-29
+- **Symptom**: LTX2 near-clean offline targets lost velocity precision after a velocity-to-x0-to-
+ velocity round trip, while exact velocity-only Wan/LTX forwards still invoked scheduler steps that
+ their loss never consumed. LTX2 I2AV also dropped cached negative prompts during preprocessing.
+- **Root Cause**: Generation-oriented forward paths performed transition reconstruction before
+ checking the requested offline component, and the I2AV preprocessing override failed to forward
+ one base prompt argument.
+- **Fix**: LTX2 retains official online reconstruction by default but opts offline forwards into raw
+ model velocity; Wan and LTX return exact velocity requests before scheduler stepping; I2AV now
+ forwards `negative_prompt` explicitly. Component, parity, and initialization regressions cover
+ the split behavior.
+- **Lesson**: Offline objectives may share a model forward with generation but must not inherit
+ numerically lossy or RNG-consuming transition work that is outside their requested output.
+- **Related Constraint**: #7
+
+### Exact resume must lock the checkpoint-realized pipeline contract
+- **Date**: 2026-08-29
+- **Symptom**: Exact resume could accept a checkpoint after an in-place model configuration change
+ switched a Wan adapter between first-only and first/last-frame semantics.
+- **Root Cause**: Runtime identity hashed model arguments and trainer execution but omitted the
+ adapter's resolved `effective_pipeline_io_contract`.
+- **Fix**: The default execution identity now canonicalizes and hashes the realized pipeline I/O
+ contract after adapter initialization; a regression changes only that contract and observes only
+ the execution digest change.
+- **Lesson**: Any checkpoint-dependent specialization that changes legal inputs or forward binding
+ is future-execution state and belongs in exact-resume identity.
+- **Related Constraint**: #18
+
+### Offline condition caches need contract-stable schemas across sources
+- **Date**: 2026-08-29
+- **Symptom**: Changing only semantic slot order could reuse an Arrow cache with the old media
+ projection, while a multi-source batch could fail because an all-empty optional source omitted
+ columns that a populated source emitted.
+- **Root Cause**: The source hash covered record identities but not the effective input projection
+ contract, and projection decided column existence from each source's observed values.
+- **Fix**: Condition source identity now includes the canonical effective input contract. With a
+ contract, negative-prompt, declared media, and semantic-slot columns are projected consistently
+ even when every row in one source is empty. A real two-source `DistributedSampler` loader
+ regression mixes empty and populated optional conditions in one batch.
+- **Lesson**: A concatenated cache schema is defined by the model contract, not by local source
+ sparsity; cache identity must cover every declaration that can reorder or reshape projection.
+- **Related Constraint**: #9
+
+### Distributed Arrow schemas must be inferred before rank sharding
+- **Date**: 2026-08-29
+- **Symptom**: A distributed condition-cache build could write `List(null)` on an all-empty rank
+ and `List(Image)` on a populated rank, then fail when the per-rank Arrow files were consolidated.
+- **Root Cause**: Cross-chunk schema discovery ran after rank sharding, so each process inferred
+ features from only its local value distribution. The standalone cache entry point also ignored a
+ pipeline's single-sample preprocessing capability.
+- **Fix**: Distributed preprocessing now derives one explicit feature schema from the full source
+ before selecting rank-local rows, and both cache entry points force batch size one for ordered or
+ `SINGLE_SAMPLE` contracts. A real two-part Arrow regression separates empty and populated rows
+ across ranks, consolidates the files, and loads the merged dataset.
+- **Lesson**: Distributed writers need a global serialization contract even when their data is
+ disjoint. Batch capability is likewise part of the preprocessing boundary, not only trainer
+ orchestration.
+- **Related Constraint**: #9
+
+### Validate output candidates before stochastic condition preparation
+- **Date**: 2026-08-29
+- **Symptom**: An invalid offline target correctly raised an exception but first consumed condition
+ preparation RNG, so retrying with corrected media no longer reproduced the original encoding.
+- **Root Cause**: `BaseAdapter.encode_output_state()` prepared raw conditions before validating the
+ generator and exact output-media sequence.
+- **Fix**: The lifecycle wrapper now validates generator type and candidate media before invoking
+ any condition preparer or codec. A stochastic-preparer regression proves invalid media leaves the
+ explicit generator unchanged and performs no preparation work.
+- **Lesson**: Pure boundary validation must precede expensive or random transformations. Failed
+ inputs should not mutate the state that determines a later valid retry.
+- **Related Constraint**: #7
+
+### Aggregate media guarantees must be canonical contract state
+- **Date**: 2026-08-29
+- **Symptom**: `INPUT_MEDIA` geometry rejected a valid contract whose aggregate `min_total_count` or
+ `required_any_types` guaranteed a condition, while semantically identical required-type tuples
+ in different orders produced different cache and resume identities.
+- **Root Cause**: Geometry validation recognized only per-type minima, and the set-like aggregate
+ field had no canonical ordering rule.
+- **Fix**: Input-derived geometry now accepts every nonempty guarantee enforced by runtime
+ validation, `required_any_types` must follow canonical media-type order, and required slots must
+ follow their declaration order.
+- **Lesson**: Declarative invariants should be interpreted consistently at construction and runtime,
+ and set-like identity fields require one canonical representation.
+- **Related Constraint**: #5
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/.agents/knowledge/topics/minimax_h3.md b/.agents/knowledge/topics/minimax_h3.md
index fd41f1962..d4f3048da 100644
--- a/.agents/knowledge/topics/minimax_h3.md
+++ b/.agents/knowledge/topics/minimax_h3.md
@@ -45,40 +45,60 @@ and lets that boundary stay strict.
## Input contracts
- T2VA accepts prompt-only workflow input.
-- FL2VA accepts one first image or two images ordered first then last.
-- Ref2VA preserves and hashes ordered image/video/audio manifests.
+- FL2VA accepts semantic `first_frame`, `last_frame`, or both image slots. Unslotted legacy
+ generation input retains first-then-last positional shorthand; strict V2 can express last-only.
+- Ref2VA preserves and hashes 1-12 ordered image/video/audio references and requires at least one
+ image or video.
- Ref2VA declares `supports_ordered_references=True`; all H3 adapters explicitly declare hidden
geometry cache fields and a preprocessing cache version.
- Reference paths are dataset-relative. Positive finite `fps` and `sample_rate` overrides follow
`samples/references.py`.
- PyAV >=18.0.0 decodes video/audio references, including embedded or separate soundtracks.
-## Offline T2VA output contract
+## Offline audiovisual output contract
-`minimax-h3-t2va` supports SFT and offline DPO with one exact ordered output pair:
-video first, then audio. Both `fps` and `sample_rate` are required in V2 supervision.
+All three H3 workflows support SFT and offline DPO with one exact ordered output pair: video first,
+then audio. Both `fps` and `sample_rate` are required in V2 supervision.
Targets are decoded on demand; neither pixels, waveforms, nor VAE latents enter the condition
cache. The pipeline's single-sample capability also forces condition-cache preprocessing to B=1,
independently of the global preprocessing batch-size setting.
-The codec cross-validates cached T2VA layout and geometry against the current training config. It
+The codec cross-validates cached layout and geometry against the current training config. It
resamples video onto the configured fixed 24-fps grid and canvas, truncates audio on its declared
source clock before a single conversion to the audio-VAE rate, and aligns stereo audio to the exact
-latent duration, samples and normalizes the video posterior, takes and normalizes the official
-audio posterior mode, then packs structured rows in `("video", "audio")` order. The codec does
+latent duration, takes and normalizes both posterior modes, then packs structured rows in
+`("video", "audio")` order. The deterministic video mode follows the H3 SFT reference data flow;
+the released Diffusers pipeline does not define an offline target encoder. The codec does
not duplicate input-owned fields in output forward context. Replay nests the flat cached layout
and derives empty T2VA condition prefixes from the current state, preserving storage dtype and
device. Exact velocity-only offline forwards return before either component scheduler steps, so
SFT and offline DPO do not sample unused transitions or perturb scheduler RNG cadence. Every
encoded row count must match the cached layout before transformer execution.
-FL2VA and Ref2VA remain online-only. Their output AV encoding can reuse the T2VA numerical
-codec, but their cached media conditions still need a separately owned, reproducible
-condition-prefix binder. In particular, both offline-DPO arms must consume the same conditioned
-prefix noise; do not generate those prefixes independently inside the chosen/rejected codecs.
+FL2VA and Ref2VA use a separately owned runtime condition-prefix preparer. It realizes the official
+condition noise once per batch, then exposes immutable model-forward and output-binding views. Both
+offline-DPO arms and their policy/reference forwards consume the same prepared prefix object; target
+encoding never draws condition noise independently. Offline flow matching sums the separate video
+and audio means, while online likelihood and distillation retain their existing globally
+element-weighted reducer.
## Fix records
+### H3 offline targets preserve the reference posterior and modality objective
+
+- **Date**: 2026-08-29
+- **Symptom**: H3 clean video targets changed on every encode and audiovisual flow loss weighted
+ modalities by tensor cardinality, heavily downweighting audio.
+- **Root Cause**: The first offline codec inferred stochastic video-posterior sampling where
+ Diffusers has no target recipe, and inherited the online global reducer for a two-term SFT loss.
+- **Fix**: Video and audio target codecs now take deterministic posterior modes, matching the H3
+ SFT reference data flow, and the offline flow hook returns `video_mean + audio_mean` without
+ changing the online reducer. Unequal-cardinality regression tests lock both decisions.
+- **Lesson**: When an inference pipeline omits training semantics, use the nominated training
+ reference for posterior selection and keep objective-specific modality weighting separate from
+ trajectory likelihood aggregation.
+- **Related Constraint**: #7, #8
+
### Offline targets preserve configured geometry and logical source clocks
- **Date**: 2026-08-28
@@ -122,11 +142,12 @@ prefix noise; do not generate those prefixes independently inside the chosen/rej
## Verification boundary
-All workflows have pinned API/schema/no-weight verification. T2VA additionally completed
+All workflows have pinned API/schema/no-weight verification and local offline codec/forward
+coverage. T2VA additionally completed
real-weight LoRA rollout, decode, reward, replay, backward, checkpoint, and resume tests on one
GPU and with FSDP2 on 16 GPUs. The native-resolution path completed initialization, checkpoint,
-decode, and evaluation. FL2VA and Ref2VA remain no-weight validated. Do not claim long-run reward
-improvement, convergence, or numerical parity.
+decode, and evaluation. FL2VA/Ref2VA SFT and offline DPO still require the documented real-weight
+GPU matrix. Do not claim long-run reward improvement, convergence, or numerical parity.
## Upgrade checklist
diff --git a/.agents/knowledge/topics/train_inference_consistency.md b/.agents/knowledge/topics/train_inference_consistency.md
index fac9faa8e..fda66c548 100644
--- a/.agents/knowledge/topics/train_inference_consistency.md
+++ b/.agents/knowledge/topics/train_inference_consistency.md
@@ -39,7 +39,14 @@ If rollout and training `forward()` diverge, `ratio` deviates from 1.0 at epoch
7. **Batch/pack composition mismatch (pack-dependent adapters)**: For adapters whose batched `forward()` is *pack-composition-dependent* (e.g. Bagel NaViT sequence packing, where a sample's linear-projection matmuls run over the concatenated `sum_seqlen` of the whole pack), bf16 rounding depends on *which* samples share the pack. If a training micro-batch packs a different sample set than the corresponding rollout pack, the on-policy `forward()` is no longer bit-identical -> `ratio != 1` (~1e-4) even though every stored argument matches. Per-sample (B=1) adapters are immune. The trigger is the optimize-time sample shuffle reordering `samples` before chunking into micro-batches.
8. **Stochastic conditioning encoder recomputed per forward**: When `forward()` *rebuilds* its conditioning from raw inputs each call (rather than replaying a stored embedding) and that encoder is non-deterministic, rollout and training diverge. Bagel I2I is the canonical case: the condition-image VAE (`DiagonalGaussian`, default `sample=True` -> `mean + std*randn`) is encoded **once** at rollout but **re-encoded every training `forward()`** (`_forward_rebuild` -> `_build_gen_context`), so each draws different noise -> different KV context -> on-policy `ratio != 1` (~2e-4). T2I is immune (text-only context, deterministic). Fix: make the condition encode deterministic (`vae.reg.sample = False`, posterior mean) or cache the rollout encoding and replay it. (Only affects `vae.encode` of conditions; generation uses init noise and `vae.decode` is unaffected.) **Bagel applies this fix**: `pipeline.vae.reg.sample = False` in `BagelAdapter.load_pipeline()`.
9. **Preference arms replayed with the wrong batch**: DPO shares forward-process noise across chosen/rejected states, but each arm still owns its conditioning batch. Both policy and reference forwards for the rejected state must receive `rejected_batch`; pairing it with `chosen_batch` evaluates the rejected trajectory under another conditioning context.
-10. **Model-specific velocity direction assumed by a trainer**: Standard flow adapters predict noise-ward velocity (`noise - clean`), while MiniMax H3 predicts data-ward velocity (`clean - noise`). Any `x0` target must use `adapter.project_velocity_to_clean_state()` rather than spelling `xt - sigma * velocity` inside a trainer.
+10. **Condition realization redrawn per candidate**: Geometry-bound VAE conditions and stochastic
+ condition prefixes belong to the input, not to a demonstration/preference candidate. Offline
+ training must call `prepare_condition_state()` once per batch and reuse the resulting tensor
+ leaves for every target arm and policy/reference forward. MiniMax H3 FL2VA/Ref2VA is the
+ stochastic reference: its official condition augmentation must be drawn once before target
+ noise. Wan I2V and LTX2 I2AV use deterministic posterior mode but follow the same ownership
+ boundary so mask/layout binding cannot drift between candidates.
+11. **Model-specific velocity direction assumed by a trainer**: Standard flow adapters predict noise-ward velocity (`noise - clean`), while MiniMax H3 predicts data-ward velocity (`clean - noise`). Any `x0` target must use `adapter.project_velocity_to_clean_state()` rather than spelling `xt - sigma * velocity` inside a trainer.
## Pack-composition-dependent adapters: `shuffle_samples`
diff --git a/README.md b/README.md
index 0b9954858..8c0a23ca1 100644
--- a/README.md
+++ b/README.md
@@ -19,10 +19,9 @@ pip install 'diffusers>=0.40.0'
pip install -e .
```
-* **[2026-04-25]** **LTX-2 Audio-Video** support! Generate synchronized audio-video content with RL fine-tuning. LTX-2 requires the bundled `diffusers` submodule (not yet in the official release):
+* **[2026-04-25]** **LTX-2 Audio-Video** support! Generate synchronized audio-video content with RL fine-tuning through the released Diffusers API:
```bash
-git submodule update --init
-pip install -e ./diffusers
+pip install 'diffusers>=0.40.0'
```
* **[2026-02-01]** Support for multiple **Attention Backends**! Attention-backend selection now lives in the unified `acceleration:` block (the old `model.attn_backend` knob was removed), where it can be combined with `torch.compile` and feature caching — applied in list order:
@@ -80,8 +79,7 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
| Wan2.2-TI2V-5B | 5B | wan2_t2v |
| Wan2.2-T2V-A14B | A14B | wan2_t2v |
- | Image-to-Video | Wan2.1-I2V-14B-480P | 14B | wan2_i2v |
- | Wan2.1-I2V-14B-480P | 14B | wan2_i2v |
+ | Image-to-Video | Wan2.1-I2V-14B-480P | 14B | wan2_i2v |
| Wan2.1-I2V-14B-720P | 14B | wan2_i2v |
| Wan2.2-TI2V-5B | 5B | wan2_i2v |
| Wan2.2-I2V-A14B | A14B | wan2_i2v |
@@ -99,17 +97,18 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
> **Offline output support:** SFT and offline DPO currently support `sd3-5`, `flux1`,
> `flux1-kontext`, `flux2`, `flux2-klein`, `qwen-image`, `qwen-image-edit-plus`, `z-image`,
-> `bagel`, `sensenova`, `wan2_t2v`, and `minimax-h3-t2va`. Wan I2V, LTX2, and the
-> conditioned MiniMax H3 FL2VA/Ref2VA workflows fail fast on their currently unresolved
-> output/condition semantics. MiniMax H3 T2VA targets use an exact ordered video/audio pair,
-> encoded on demand into its structured latent state. See the
+> `bagel`, `sensenova`, `wan2_t2v`, `wan2_i2v`, `ltx2_t2av`, `ltx2_i2av`, and all
+> MiniMax H3 workflows. Video/audio targets are encoded on demand and are never written to
+> the preprocessing cache. Conditioned adapters prepare one immutable condition state per batch;
+> offline DPO shares that exact realization across chosen and rejected arms. See the
> [offline model matrix](guidance/datasets.md#offline-model-support).
> **MiniMax H3 status:** the T2VA debug and
> [native-quality FSDP2](examples/grpo/lora/minimax_h3_t2va/quality_720p_fsdp2.yaml)
> paths are real-weight
> validated; a completed long-run reward trend is not claimed. FL2VA and Ref2VA remain
-> schema/API validated. H3 requires B=1, has no CFG, uses neutral guidance `1.0`, and
+> schema/API and local offline-path validated, pending the documented real-weight GPU matrix.
+> H3 requires B=1, has no CFG, uses neutral guidance `1.0`, and
> keeps separate video/audio trajectories.
> Video uses shift 12, audio uses shift 3, and the model predicts data-ward velocity.
> `num_inference_steps=N` means N transitions and N + 1 states.
@@ -160,16 +159,10 @@ pip install -e .[deepspeed]
> **Note**: The Bagel adapter requires `flash-attn` (>= 2.5.8) and `opencv-python`. Install them with `pip install -e .[bagel]` (the `[bagel]` extra is intentionally not part of `[all]` because flash-attn is heavy to build).
-> **Dependency:** MiniMax H3 requires `diffusers>=0.40.0`. PyAV >=18.0.0 decodes
-> ordered video/audio references.
-
-> **Note**: Some models (e.g., LTX-2) require pipeline code not yet released in the official `diffusers` package. For these models, install the bundled diffusers submodule:
-> ```bash
-> git submodule update --init
-> pip install -e ./diffusers
-> ```
+> **Dependency:** MiniMax H3 and LTX2 require the released `diffusers>=0.40.0` API.
+> PyAV >=18.0.0 decodes ordered video/audio references and target media.
-A CUDA training image (Python 3.12, **uv**-based install, PyTorch 2.8 + `cu129`, `deepspeed`, `wandb`, bundled `diffusers`) is defined under [`docker/docker-cuda/`](docker/docker-cuda/Dockerfile). See [`docker/README.md`](docker/README.md) for build and run instructions (including `linux/amd64` on Apple Silicon).
+A CUDA training image (Python 3.12, **uv**-based install, PyTorch 2.8 + `cu129`, `deepspeed`, `wandb`, released `diffusers`) is defined under [`docker/docker-cuda/`](docker/docker-cuda/Dockerfile). See [`docker/README.md`](docker/README.md) for build and run instructions (including `linux/amd64` on Apple Silicon).
## Experiment Trackers
@@ -240,12 +233,17 @@ SFT and offline DPO use strict JSONL with `schema_version: 2`. Public media obje
```jsonl
{"schema_version":2,"input":{"prompt":"A clean poster.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"targets/poster.png"}]}},"metadata":{}}
{"schema_version":2,"input":{"prompt":"A clean poster.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"pairs/chosen.png"}]},"rejected":{"media":[{"type":"image","path":"pairs/rejected.png"}]}},"metadata":{}}
+{"schema_version":2,"input":{"prompt":"Animate toward this ending.","media":[{"type":"image","path":"conditions/end.png","slot":"last_frame"}]},"supervision":{"type":"demonstration","target":{"media":[{"type":"video","path":"targets/story.mp4","fps":24.0},{"type":"audio","path":"targets/story.wav","sample_rate":32000}]}},"metadata":{}}
```
+The optional input-only `slot` field binds sparse conditions to adapter-declared semantic
+arguments. Unslotted media fills remaining slots positionally; supervision outputs reject slots.
+
Prompt and input-condition encodings are cached. Target, chosen, and rejected media are decoded and
encoded on the fly; their VAE latents are never stored in the preprocessing cache. One offline
epoch is one complete dataloader traversal sharded by PyTorch's official `DistributedSampler`. See the
-[dataset guide](guidance/datasets.md#offline-v2-records) for the full schema and cadence rules.
+[dataset guide](guidance/datasets.md#offline-v2-records) for the full schema and cadence rules, and
+the [GPU validation plan](guidance/gpu_validation.md) for the 120-job model/backend/algorithm matrix.
## Text-to-Image & Text-to-Video
diff --git a/docker/README.md b/docker/README.md
index 1196d8d43..ced5c85de 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -31,12 +31,11 @@ docker run --rm -it --gpus all ghcr.io/x-gengroup/flow-factory:0.1.0
### B. Build locally
-Clone with the `diffusers` submodule (required):
+Clone the repository:
```bash
-git clone --recursive https://github.com/X-GenGroup/Flow-Factory.git
+git clone https://github.com/X-GenGroup/Flow-Factory.git
cd Flow-Factory
-# or, if already cloned: git submodule update --init --recursive
```
Build from the **repository root**:
@@ -103,7 +102,7 @@ Do not commit secrets; use environment variables or your orchestrator's secret s
|---|---|---|
| `nvidia-smi` not found in container | NVIDIA Container Toolkit not installed | [Install the toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) and restart Docker |
| `CUDA out of memory` | Batch size too large for GPU VRAM | Reduce batch size, enable DeepSpeed ZeRO-2, or use FSDP |
-| Build fails on `diffusers` install | Submodule not initialized | Run `git submodule update --init --recursive` |
+| Diffusers API import fails | Installed package is older than 0.40.0 | Rebuild the image or install `diffusers>=0.40.0` |
| Every source change triggers full rebuild | Expected with `COPY . /app` | The Dockerfile uses two-phase COPY for layer caching; ensure `pyproject.toml` is unchanged for cache hits |
## Building tips
diff --git a/docker/docker-cuda/Dockerfile b/docker/docker-cuda/Dockerfile
index 0ca46dbc9..91d63bdbf 100644
--- a/docker/docker-cuda/Dockerfile
+++ b/docker/docker-cuda/Dockerfile
@@ -1,4 +1,4 @@
-# Build from repository root (includes diffusers submodule):
+# Build from repository root:
# docker buildx build --platform linux/amd64 -f docker/docker-cuda/Dockerfile -t flow-factory:local --load .
#
# Apple Silicon: always pass --platform linux/amd64 for this training image.
@@ -56,10 +56,9 @@ RUN uv pip install ".[deepspeed,wandb]"
# --- Phase 2: copy full source and re-link editable installs ---
COPY . /app
RUN uv pip install --no-deps -e .
-RUN uv pip install -e ./diffusers
# Fail fast if any layer is broken
-RUN python -c "import deepspeed, torch, wandb; print('torch', torch.__version__)" && ff-train --help >/dev/null
+RUN python -c "import deepspeed, diffusers, torch, wandb; assert tuple(map(int, diffusers.__version__.split('.')[:2])) >= (0, 40); print('torch', torch.__version__, 'diffusers', diffusers.__version__)" && ff-train --help >/dev/null
# Intentionally root: DeepSpeed / NCCL may need elevated IPC access.
CMD ["/bin/bash"]
diff --git a/examples/README.md b/examples/README.md
index 2dea62db8..2765c7800 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,6 +1,6 @@
# Examples
-Training configs for all supported algorithm–model combinations.
+Curated training configs for representative supported algorithm–model combinations.
## Directory Structure
@@ -92,7 +92,9 @@ Its 64x96 canvas is intentionally a correctness geometry. The quality-oriented T
default is now the shared-`vid_prompt`, LoRA-rank-64 baseline aligned with the LTX2
T2AV recipe and uses both CLAP and ImageBind rewards. It is configuration/API
validated; no completed long-run reward trend is claimed. FL2VA and Ref2VA are also
-**Schema/API validated only**, rather than claims of training stability or reward improvement.
+schema/API and local offline-path validated, rather than claims of real-weight training stability
+or reward improvement. The complete follow-up campaign is defined in the
+[GPU validation plan](../guidance/gpu_validation.md).
The T2VA `quality_720p_fsdp2.yaml` recipe is the active native-quality path:
768x1344, 124 frames, 24 denoising steps, LoRA rank 64 / alpha 128, and two
diff --git a/guidance/datasets.md b/guidance/datasets.md
index ff0d74726..7a87075e0 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -73,10 +73,17 @@ Every V2 media object uses `type` as its only public discriminator. The accepted
```json
{"type":"image","path":"images/source.png"}
+{"type":"image","path":"images/end.png","slot":"last_frame"}
{"type":"video","path":"videos/clip.mp4","fps":24.0}
{"type":"audio","path":"audios/clip.wav","sample_rate":48000}
```
+`slot` is an optional, input-only semantic binding. It is useful when position alone is ambiguous:
+an explicitly slotted item reserves that adapter-declared slot, while unslotted items fill the
+remaining slots in declaration order. Duplicate, unknown, and wrong-media-type slots fail during
+contract validation. Supervision outputs reject `slot` because their order is declared by the
+pipeline output contract rather than by condition-argument names.
+
Do not write `kind` in a V2 record. Some ordered-reference adapters still consume a validated
legacy `kind` mapping internally; the V2 condition projection creates that private bridge only at
the adapter preprocessing boundary. It is not part of the public V2 schema.
@@ -137,10 +144,18 @@ V2 input
V2 target, chosen, or rejected
-> decode from the source file in Dataset.__getitem__
-> collate decoded CPU media
- -> adapter.encode_output_state under no-grad on every training microbatch
+ -> adapter.prepare_condition_state once under no-grad on every training microbatch
+ -> adapter.encode_output_state with that prepared state under no-grad
-> clean latent state for the objective
```
+The prepared condition keeps immutable cached condition fields and exposes explicit model-forward
+and output-codec views. Those two views may intentionally reference the same prepared input tensor;
+the framework prevents accidental key overwrites rather than requiring artificial tensor copies.
+SFT uses the same object for target binding and the forward.
+Offline DPO uses one object for both chosen/rejected encodes and both policy/reference forwards, so
+stochastic condition realization cannot drift between preference arms.
+
Target, chosen, and rejected payloads, their VAE latents, and supervision metadata are never
written to the Arrow condition cache. There is no target-VAE preprocessing cache. This avoids a
second large media-derived dataset on disk and keeps output geometry and posterior semantics owned
@@ -151,7 +166,10 @@ During offline dataset construction, every unique normalized input and supervisi
streamed once through SHA-256, with digests memoized only for that source build. These digests are
identity metadata: media payloads, decoded pixels, and output latents are neither copied nor cached.
Replacing an input condition file in place therefore changes its condition identity and invalidates
-the Arrow cache automatically. Replacing target, chosen, or rejected media in place changes the
+the Arrow cache automatically. The checkpoint-realized input projection contract is also part of
+the cache key, so changes to slot order, binding, aggregate rules, negative-prompt policy, or batch
+capability cannot reuse an incompatible Arrow schema. Replacing target, chosen, or rejected media
+in place changes the
full record identity used by exact-resume checks; supervision is still decoded afresh and requires
no target-cache invalidation step.
@@ -203,21 +221,40 @@ output semantics.
|---|---|---|
| Supported | `sd3-5`, `flux1`, `flux1-kontext`, `flux2`, `flux2-klein`, `qwen-image`, `qwen-image-edit-plus`, `z-image`, `bagel`, `sensenova` | Image-output codecs with adapter-specific geometry and packing. SenseNova uses the existing grouped `images` input with within-type order, not heterogeneous references. |
| Supported | `wan2_t2v` | Video targets require `fps`; the codec resamples to configured frames/rate and samples the Wan VAE posterior on the fly. |
-| Supported | `minimax-h3-t2va` | Every candidate is an exact ordered `(video, audio)` pair with required `fps` and `sample_rate`. The codec aligns both streams to configured H3 geometry, samples the video posterior, takes the official audio-posterior mode, and packs structured video/audio rows on the fly. Condition preprocessing and training remain B=1. |
-| Blocked | `wan2_i2v` | Output geometry depends on the first-frame VAE latent/mask, while the current condition cache does not preserve the source pixels needed by that binder. |
-| Blocked | `ltx2_t2av`, `ltx2_i2av` | Their adapter codec still needs exact LTX-specific audio/video duration alignment, latent packing, and decode context; I2AV also needs the pinned first-frame active mask. |
-| Blocked | `minimax-h3-fl2va`, `minimax-h3-ref2va` | Their cached input media still need a shared, reproducible offline condition-prefix binder; offline DPO must reuse the same conditioned prefix noise for both preference arms. |
+| Supported | `wan2_i2v` | Input media binds a required `first_frame` image and an optional `last_frame` image. Condition pixels are cached at configured geometry, then encoded with VAE posterior mode once per batch. Expanded-timestep TI2V checkpoints accept the first frame only because official Diffusers ignores a last image in that mode. Video targets require `fps`. Offline execution is B=1. |
+| Supported | `ltx2_t2av`, `ltx2_i2av` | Every candidate is an exact ordered `(video, audio)` pair with required `fps` and `sample_rate`. Both streams are aligned to the official LTX2 clock and encoded/packed on the fly. I2AV requires the `first_frame` image slot, substitutes its posterior-mode first latent into each target, and excludes the pinned tokens with an active mask. |
+| Supported | `minimax-h3-t2va`, `minimax-h3-fl2va`, `minimax-h3-ref2va` | Every candidate is an exact ordered `(video, audio)` pair. FL2VA accepts `first_frame`, `last_frame`, or both slots; Ref2VA accepts 1-12 globally ordered references and requires at least one image or video. Conditioned workflows realize one official prefix per batch, shared by both offline-DPO candidates and policy/reference forwards. H3 remains B=1. |
+
+Wan first/last semantics use generic semantic slots, not model-specific schema keys. The first
+frame is required; the last frame is optional. Unslotted input remains a positional convenience,
+but an explicit slot is recommended for sparse or generated manifests. The target is the complete
+generated video: its first frame, and its final frame when provided, correspond to the conditions.
+
+```jsonl
+{"schema_version":2,"input":{"prompt":"A paper boat crosses the pond.","media":[{"type":"image","path":"conditions/first.png","slot":"first_frame"}]},"supervision":{"type":"demonstration","target":{"media":[{"type":"video","path":"targets/first-only.mp4","fps":24.0}]}},"metadata":{}}
+{"schema_version":2,"input":{"prompt":"Interpolate the changing sky.","media":[{"type":"image","path":"conditions/first.png","slot":"first_frame"},{"type":"image","path":"conditions/last.png","slot":"last_frame"}]},"supervision":{"type":"preference","chosen":{"media":[{"type":"video","path":"pairs/chosen.mp4","fps":24.0}]},"rejected":{"media":[{"type":"video","path":"pairs/rejected.mp4","fps":24.0}]}},"metadata":{}}
+```
-MiniMax H3 T2VA supervision lists video first and audio second. The target video must cover the
-configured 24-fps duration; it is deterministically sampled onto that frame grid and resized to the
-configured canvas. Audio is converted to stereo at the H3 audio-VAE rate, then trimmed or
-right-padded to the exact aligned latent duration. For example:
+LTX2 and MiniMax H3 supervision list video first and audio second. LTX2 aligns audio duration with
+the official `num_frames / frame_rate` clock. H3 target video must cover the configured 24-fps
+duration; it is deterministically sampled onto that frame grid and resized to the configured
+canvas. H3 audio is converted to stereo at the audio-VAE rate, then trimmed or right-padded to the
+exact aligned latent duration. For example:
```jsonl
{"schema_version":2,"input":{"prompt":"Ocean waves beneath an aurora.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"video","path":"targets/aurora.mp4","fps":24.0},{"type":"audio","path":"targets/aurora.wav","sample_rate":32000}]}},"metadata":{}}
{"schema_version":2,"input":{"prompt":"Ocean waves beneath an aurora.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"video","path":"pairs/chosen.mp4","fps":24.0},{"type":"audio","path":"pairs/chosen.wav","sample_rate":32000}]},"rejected":{"media":[{"type":"video","path":"pairs/rejected.mp4","fps":24.0},{"type":"audio","path":"pairs/rejected.wav","sample_rate":32000}]}},"metadata":{}}
```
+For LTX2 I2AV, bind one image to `first_frame`. H3 FL2VA supports first-only, last-only, and
+first-plus-last records; use explicit slots for the last-only form. H3 Ref2VA puts the complete
+ordered image/video/audio reference sequence in `input.media`; the offline projection bridges those
+public `type` objects to the adapter's private legacy reference representation.
+
+```jsonl
+{"schema_version":2,"input":{"prompt":"Reveal the scene before this ending.","media":[{"type":"image","path":"conditions/end.png","slot":"last_frame"}]},"supervision":{"type":"demonstration","target":{"media":[{"type":"video","path":"targets/story.mp4","fps":24.0},{"type":"audio","path":"targets/story.wav","sample_rate":32000}]}},"metadata":{}}
+```
+
## Common task formats
The following compact formats remain supported for generation acquisition. They are separate from
@@ -367,6 +404,10 @@ FL2VA uses an ordered `"images"` list:
- Any other cardinality is invalid.
- Order must not be sorted, deduplicated, or inferred from filenames.
+This legacy generation form cannot express last-only conditioning. Strict V2 offline records use
+the generic `slot: "last_frame"` binding shown above, so FL2VA can train on a final frame without a
+synthetic first frame.
+
The example stores paths relative to the dataset root, so its YAML sets `image_dir` to the dataset
directory. See the [FL2VA dataset fixture](../dataset/minimax_h3_fl2va/train.jsonl) and
[FL2VA GRPO configuration](../examples/grpo/lora/minimax_h3_fl2va/default.yaml).
@@ -381,8 +422,8 @@ image, video, and audio entries:
```
Array order is semantically significant. It is preserved during validation, encoding, caching, and
-sample identity hashing. At least one image or video reference is required; an audio-only array is
-invalid.
+sample identity hashing. The array accepts 1-12 entries and requires at least one image or video;
+an audio-only array is invalid.
Supported entries:
@@ -394,7 +435,7 @@ Supported entries:
`fps` and `sample_rate` overrides must be finite positive numbers. A video may use its embedded
soundtrack or a separate dataset-relative `audio_path`; a video `sample_rate` override requires
-`audio_path`. Unknown keys and unsupported `kind` values fail before preprocessing.
+`audio_path`. Unknown keys and unsupported legacy `kind` values fail before preprocessing.
This legacy online manifest is distinct from the strict V2 format above. A V2 record always uses
`input.media[*].type`; offline condition projection performs any required legacy `kind` conversion
@@ -419,8 +460,9 @@ TXT/JSONL row
Prompt encoders, condition VAEs, and processors are preprocessing components. Online RL can
offload them after cache creation because optimization consumes cached conditions. Offline SFT and
-offline DPO reload any output-codec components declared by the adapter and encode target,
-chosen, and rejected media on the fly; those output states are never cached.
+offline DPO reload any condition-preparer and output-codec components declared by the adapter.
+They realize one prepared condition per batch, then encode target, chosen, and rejected media on
+the fly; those output states are never cached.
Ref2VA adds an ordered-reference path:
diff --git a/guidance/gpu_validation.md b/guidance/gpu_validation.md
new file mode 100644
index 000000000..ede5e295f
--- /dev/null
+++ b/guidance/gpu_validation.md
@@ -0,0 +1,240 @@
+# GPU Validation Plan
+
+This document is the handoff contract for real-weight GPU validation. It does
+not claim that an unexecuted combination is supported. A combination becomes
+validated only after its artifacts satisfy the acceptance criteria below.
+
+## Environment gate
+
+Record the following once for every validation campaign:
+
+- Flow-Factory commit SHA and parent stacked-PR SHA.
+- Python, CUDA, PyTorch, Accelerate, DeepSpeed, and Diffusers versions.
+- `diffusers>=0.40.0`; do not mix results from an older official release.
+- GPU model, GPU count, per-GPU memory, driver, and NCCL versions.
+- Exact model revision and dataset-content hash.
+- `PYTHONPATH` and editable-install source must resolve to the tested worktree.
+
+Run a configuration/import preflight before allocating model weights:
+
+```bash
+python -m compileall -q src/flow_factory
+python -c "import diffusers; assert tuple(map(int, diffusers.__version__.split('.')[:2])) >= (0, 40)"
+```
+
+## Main experiment matrix
+
+The main campaign is the Cartesian product of the ten semantic modes, three
+distributed backends, and four algorithms below: **10 x 3 x 4 = 120 jobs**.
+Do not collapse first-frame and first/last-frame Wan rows: they exercise
+different condition layouts and active masks.
+
+### Semantic modes
+
+| ID | Mode | Representative checkpoint | Required input | Required output |
+|---|---|---|---|---|
+| `sd35-t2i` | SD3.5 text-to-image regression anchor | `stabilityai/stable-diffusion-3.5-medium` | prompt | image |
+| `bagel-mri2i` | Bagel ordered multi-reference-images-to-image regression anchor | `ByteDance-Seed/BAGEL-7B-MoT` | prompt plus exactly two ordered images per sample | image |
+| `wan-t2v` | Wan text-to-video | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | prompt | video |
+| `wan-i2v-first` | Wan first-frame-to-video | `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | exactly one first-frame image | video |
+| `wan-flf2v` | Wan first/last-frame-to-video | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | ordered first and last images | video |
+| `ltx2-t2av` | LTX2 text-to-audio-video | `Lightricks/LTX-2` | prompt | ordered video and audio |
+| `ltx2-i2av` | LTX2 image-to-audio-video | `Lightricks/LTX-2` | prompt plus one image | ordered video and audio |
+| `h3-t2va` | MiniMax H3 text-to-video-audio | `MiniMaxAI/MiniMax-H3` | prompt | ordered video and audio |
+| `h3-fl2va` | MiniMax H3 sparse first/last-frame-to-video-audio | `MiniMaxAI/MiniMax-H3` | `first_frame`, `last_frame`, or both image slots | ordered video and audio |
+| `h3-ref2va` | MiniMax H3 ordered-reference-to-video-audio | `MiniMaxAI/MiniMax-H3` | 1-12 ordered heterogeneous references, including image or video | ordered video and audio |
+
+The public V2 schema uses the `type` discriminator and an optional input-only
+`slot`. An explicit slot reserves its adapter-declared semantic argument;
+unslotted media is only a positional shorthand that fills remaining slots in
+declaration order. Wan requires `first_frame` and optionally accepts
+`last_frame`; H3 FL accepts either slot or both. Supervision media does not
+repeat independent condition-image objects. Its video is nevertheless the full
+configured output sequence, whose first and/or last endpoint must correspond to
+the supplied endpoint conditions.
+
+### Backends
+
+| ID | `config_file` | Required observation |
+|---|---|---|
+| `ddp` | `config/accelerate_configs/multi_gpu.yaml` | Every rank executes the same branch and step count. |
+| `zero2` | `config/deepspeed/deepspeed_zero2.yaml` | Optimizer state is partitioned and both policy/reference scopes complete. |
+| `fsdp2` | `config/accelerate_configs/fsdp2.yaml` | Adapter wrap plan, DTensor parameters, checkpointing, and component routing remain valid. |
+
+### Algorithms and stopping rules
+
+| ID | `train.trainer_type` | Exact smoke length | Evaluation |
+|---|---|---|---|
+| `grpo` | `grpo` | two training epochs, one optimizer step per epoch | `eval.eval_freq: 0` |
+| `sft` | `sft` | exactly two rank-local dataloader batches | `eval.eval_freq: 0` |
+| `offline-dpo` | `offline-dpo` | exactly two rank-local dataloader batches | `eval.eval_freq: 0` |
+| `tdm` | `tdm` | two training epochs, one generator/fake update cycle per epoch | `eval.eval_freq: 0` |
+
+For SFT and offline DPO, build a finite dataset whose official
+`DistributedSampler` yields exactly two batches per rank, set
+`gradient_accumulation_steps: 1`, and run one complete dataloader epoch. One
+offline epoch means one complete dataloader traversal. Sampler tail padding is
+standard PyTorch behavior and does not change that definition.
+
+For GRPO and TDM, use `max_epochs: 2`,
+`gradient_step_per_epoch: 1`, the smallest valid group size, and no evaluation.
+Reduce resolution, frame count, and inference steps only within each adapter's
+declared geometry constraints. MiniMax H3 must retain at least five seconds of
+24-fps output even in a low-resolution smoke run.
+
+### Concrete smoke profiles
+
+Use these geometry and sampling overlays unless a real checkpoint rejects the
+reduced geometry. Any fallback must stay valid for the adapter contract and be
+recorded in the resolved YAML; do not silently return to a large quality recipe.
+
+| Mode | Starting recipe | Train geometry | Steps | Algorithm-specific notes |
+|---|---|---|---|---|
+| `sd35-t2i` | `examples/grpo/lora/sd3_5/default.yaml` | `resolution: 256` | `num_inference_steps: 2` | Use the checked-in SD3.5 SFT/offline-DPO/TDM recipe as the algorithm overlay. |
+| `bagel-mri2i` | `examples/grpo/lora/bagel/i2i.yaml` | `resolution: 256` | `num_inference_steps: 2` | Every row has exactly two ordered references; keep `shuffle_samples: false`. |
+| `wan-t2v` | `examples/grpo/lora/wan21/t2v.yaml` | `resolution: 240`, `num_frames: 5` | `num_inference_steps: 2` | Target video has at least five frames and carries its source `fps`. |
+| `wan-i2v-first` | `examples/grpo/lora/wan22/i2v.yaml` | `resolution: 240`, `num_frames: 5` | `num_inference_steps: 2` | Use TI2V-5B and exactly one condition image. |
+| `wan-flf2v` | `examples/grpo/lora/wan21/i2v.yaml` | `resolution: 240`, `num_frames: 5` | `num_inference_steps: 2` | Use two ordered condition images; do not use an expanded-timestep checkpoint. |
+| `ltx2-t2av` | `examples/grpo/lora/ltx2/t2av.yaml` | `resolution: [128, 192]`, `num_frames: 9`, `frame_rate: 24.0` | `num_inference_steps: 2` | AV targets cover the exact 9-frame clock; audio carries `sample_rate`. |
+| `ltx2-i2av` | `examples/grpo/lora/ltx2/i2av.yaml` | `resolution: [128, 192]`, `num_frames: 9`, `frame_rate: 24.0` | `num_inference_steps: 2` | One condition image; verify the first latent frame is inactive in the loss. |
+| `h3-t2va` | `examples/grpo/lora/minimax_h3_t2va/debug.yaml` | `resolution: [64, 96]`, `num_frames: 124`, `frame_rate: 24.0` | `num_inference_steps: 2` | Preserve the released five-second minimum and neutral guidance. |
+| `h3-fl2va` | `examples/grpo/lora/minimax_h3_fl2va/default.yaml` plus H3 debug geometry | `resolution: [64, 96]`, `num_frames: 124`, `frame_rate: 24.0` | `num_inference_steps: 2` | The two offline records are explicit `first_frame`-only and `last_frame`-only cases. Cover both slots together in the additional variant gate. |
+| `h3-ref2va` | `examples/grpo/lora/minimax_h3_ref2va/default.yaml` plus H3 debug geometry | `resolution: [64, 96]`, `num_frames: 124`, `frame_rate: 24.0` | `num_inference_steps: 2` | Preserve heterogeneous global reference order; include image, video, and audio references. |
+
+For GRPO set `group_size: 2`, `unique_sample_num_per_epoch: 1`, and
+`gradient_accumulation_steps: auto`; this avoids a degenerate one-candidate
+advantage while retaining one optimizer step per epoch. For TDM set
+`group_size: 1` and `gradient_accumulation_steps: auto`; with the two-step smoke
+profile the resolved accumulation count must be divisible by two. SFT and
+offline DPO use `gradient_accumulation_steps: 1`. Use at least two distributed
+ranks for every backend; increase the rank count only for checkpoint memory
+capacity, without changing the two iteration/batch stopping rule.
+
+The offline fixture must contain exactly `2 * world_size` records and use
+`per_device_batch_size: 1`, so `DistributedSampler(drop_last=False)` yields two
+non-padded batches on every rank. Demonstration and preference fixtures use the
+same input distribution. Preference rows must use distinct chosen/rejected
+media files with identical geometry. Before launch, validate conditioned target
+endpoints against their inputs: LTX2 I2AV and first-conditioned Wan/H3 targets
+must begin with the supplied first frame within the fixture tolerance; a target
+with a last-frame condition must end with that supplied frame. Store the
+comparison metric and tolerance in the fixture manifest.
+
+The campaign generator should materialize one job ID for every Cartesian-product
+cell as `{mode}__{backend}__{algorithm}` and assert that the set has exactly 120
+unique IDs before submission. A skipped or infrastructure-blocked cell remains
+in the result table with its failure classification; it must not silently reduce
+the matrix.
+
+## Checkpoint-variant coverage
+
+The 120-job main matrix uses one checkpoint per semantic mode. Run the following
+additional variant gate before declaring a family generally supported. The
+minimum gate is DDP plus SFT and offline DPO for two batches each; configuration
+construction and static contract validation must also pass under all three
+backends and all four algorithms.
+
+| Family mode | Additional checkpoint variants |
+|---|---|
+| Wan T2V | `Wan2.1-T2V-14B-Diffusers`, `Wan2.2-TI2V-5B-Diffusers`, `Wan2.2-T2V-A14B-Diffusers` |
+| Wan I2V first-only | `Wan2.1-I2V-14B-480P-Diffusers`, `Wan2.1-I2V-14B-720P-Diffusers`, `Wan2.2-I2V-A14B-Diffusers` |
+| Wan first/last | `Wan2.1-I2V-14B-720P-Diffusers`, `Wan2.2-I2V-A14B-Diffusers` |
+| LTX2 T2AV and I2AV | `Lightricks/LTX-2.3` or its canonical official-Diffusers repository revision |
+| MiniMax H3 | The same checkpoint is covered separately by T2VA, FL2VA, and Ref2VA inputs. Add an FL2VA first-plus-last fixture to complement the first-only/last-only main jobs. |
+
+Wan2.2 TI2V-5B uses expanded timesteps. Official Diffusers ignores a supplied
+last image in that mode, so its effective input contract is first-frame only;
+do not count it as a first/last checkpoint. Other Wan I2V checkpoints must prove
+both first-only and first/last execution.
+
+For the Wan2.2 A14B dual-transformer gate, force or instrument two offline
+timestep samples so that one routes below the transformer boundary and one
+routes at or above it. Record both sampled timesteps and the selected component.
+Two unconstrained random batches are not sufficient evidence that both intended
+trainable transformers updated.
+
+## Per-job overrides
+
+Start from the closest checked-in example and apply a reviewable overlay. Every
+generated job must make these values explicit:
+
+```yaml
+log:
+ logging_backend: none
+ save_freq: 0
+
+train:
+ per_device_batch_size: 1
+ ema_decay: 0
+ enable_gradient_checkpointing: true
+ seed: 42
+ max_epochs: 2 # Use 1 for the finite two-batch offline fixture.
+
+eval:
+ eval_freq: 0
+```
+
+Offline jobs additionally require a homogeneous V2 manifest, unit source
+weights, on-the-fly target media encoding, and `num_train_timesteps: 1`.
+Offline DPO chosen/rejected media must share one prepared condition state, the
+same component times, and the same diffusion noise. Do not add a target-latent
+preprocessing cache.
+
+GRPO jobs should use the smallest reward model that still exercises reward
+routing. TDM is reward-free and needs both generator and fake optimizer
+entries. Disable W&B and checkpoint saving for the smoke campaign.
+
+Bagel workers require the project `bagel` extra in addition to the selected
+distributed backend, including a compatible `flash-attn` build and OpenCV. For
+example, a DeepSpeed worker should install `.[deepspeed,bagel]`. Record the
+installed optional reward extras in the environment manifest as well; a missing
+Bagel or reward dependency is an `import` failure, not a model-support result.
+
+Keep the two-reference Bagel cardinality identical on every rank in the FSDP2
+main-matrix job. Ragged per-rank reference-round counts are a separate stress
+case because a sharded language model must execute the same number of parameter
+all-gathers on every rank.
+
+## Acceptance criteria
+
+A job passes only when all of the following are captured:
+
+1. Configuration parsing, registry lookup, and all imports succeed from the
+ tested worktree.
+2. Model loading preserves the adapter's component-dtype manifest and produces
+ no unexpected FP32/BF16/FP16 coercion.
+3. Every rank reports identical branch routing, batch count, optimizer-step
+ count, and terminal status; no rank hangs at a collective.
+4. Both iterations/batches produce finite loss, finite gradient norm, and a
+ confirmed parameter update for every intended trainable component.
+5. SFT target encoding runs on the fly. Offline DPO reuses one prepared input
+ realization for chosen and rejected outputs and keeps paired noise/time
+ coupling exact.
+6. Conditioned video/audio jobs retain their pinned condition regions through
+ noising and forward loss masks. Unconditioned target regions remain active.
+7. Joint audio-video jobs report both components, aligned durations, valid
+ component-specific scheduler times, and the intended objective reduction.
+8. GRPO replay likelihood and TDM replay checks satisfy the configured
+ tolerances. Condition media and derived prefixes are retained in every
+ conditioned mode. TDM may intentionally skip decoding generated output media
+ when its algorithm contract does not consume it.
+9. Peak allocated/reserved GPU memory and wall time are recorded for capacity
+ planning.
+10. The command, resolved YAML, stdout/stderr log, environment manifest, and a
+ compact metrics JSON are attached to the job result.
+
+## Failure classification
+
+Classify failures before retrying:
+
+- `contract`: schema, media cardinality/order, geometry, or capability mismatch.
+- `import`: dependency, registry, optional component, or worktree-resolution error.
+- `model`: official pipeline/adapter semantic mismatch.
+- `algorithm`: loss, replay, reference-policy, or objective coupling error.
+- `backend`: DDP/DeepSpeed/FSDP2 wrapping, collective, dtype, or checkpoint issue.
+- `capacity`: reproducible OOM after the allowed geometry reductions.
+- `infrastructure`: download, permission, driver, filesystem, or cluster failure.
+
+Do not mark `capacity` or `infrastructure` failures as unsupported model/algorithm
+combinations. Preserve the first failing artifact and link any follow-up fix to
+the exact job ID `{mode}__{backend}__{algorithm}`.
diff --git a/guidance/new_model.md b/guidance/new_model.md
index 025686559..71367702d 100644
--- a/guidance/new_model.md
+++ b/guidance/new_model.md
@@ -218,7 +218,12 @@ preprocess_func(prompt, images, videos, audios, **kwargs):
return results
```
-Text-to-image models override only `encode_prompt` and `encode_image`; image-to-video models add `encode_video`; audio-conditioned models add `encode_audio`. There is no need to add stub `pass` overrides for unused modalities — `BaseAdapter` already provides them.
+Text-to-image, text-to-video, and text-to-audio-video adapters usually override only
+`encode_prompt` because they have no condition media. Image-conditioned tasks add
+`encode_image`; video-conditioned tasks add `encode_video`; audio-conditioned tasks add
+`encode_audio`. These functions encode inputs, not supervised outputs—offline targets belong to
+the output-state codec. There is no need to add stub `pass` overrides for unused modalities;
+`BaseAdapter` already provides them.
#### `encode_prompt`
@@ -554,17 +559,29 @@ Online-only adapters can keep the default `build_output_state_codec() -> None`.
offline DPO, an adapter must additionally declare both sides of its pipeline and provide an
on-the-fly output codec:
-1. Set a class-level `pipeline_io_contract`. It owns input media counts/order/binding, negative
- prompt policy, the exact ordered output media sequence, rate requirements, geometry source, and
- batch capability.
-2. Override `build_output_state_codec()` with a declaration-only codec. Its
+1. Set a class-level `pipeline_io_contract`. It owns per-type and aggregate input counts,
+ order/binding, optional semantic input slots, negative prompt policy, the exact ordered output
+ media sequence, rate requirements, geometry source, and batch capability. Explicit V2 slots
+ reserve declared arguments; unslotted inputs fill the remaining slots in declaration order, and
+ output media must never carry slots. If checkpoints behind one adapter expose narrower behavior, override
+ `_resolve_pipeline_io_contract()` and return an immutable instance-specific specialization;
+ offline data validation consumes `effective_pipeline_io_contract`.
+2. If cached input fields are not already the exact forward condition, override
+ `build_condition_state_preparer()` with a declaration-only preparer. Its
+ `required_components` lists runtime encoders and `prepare_condition_state()` returns one
+ `PreparedConditionState` per batch. Put input-owned model fields in `forward_context` and
+ input-owned target-binding fields in `output_context`. The two runtime consumer views may
+ intentionally share an input-owned tensor (for example a mask or layout), but each merged
+ consumer view must remain collision-free with cached fields and later candidate-owned output
+ fields.
+3. Override `build_output_state_codec()` with a declaration-only codec. Its
`required_components` names logical runtime components such as `("vae",)`; construction must
not load, materialize, move, replace, or cast them.
-3. Return an `EncodedOutputState` containing a detached `LatentState`, output-derived forward and
+4. Return an `EncodedOutputState` containing a detached `LatentState`, output-derived forward and
decode contexts, and one exact geometry signature per sample.
-4. Override `_validate_encoded_output_geometry()` so configured, condition-derived, and
+5. Override `_validate_encoded_output_geometry()` so configured, condition-derived, and
output-derived dimensions cannot drift silently.
-5. Declare a complete immutable `offline_training_forward_overrides` mapping whenever the base
+6. Declare a complete immutable `offline_training_forward_overrides` mapping whenever the base
`{"guidance_scale": 1.0}` contract does not describe the adapter. Offline trainers apply this
mapping after sampling configuration and cached batch conditions, so it owns loss-time model
conditioning. Conventional CFG branches must all be set to their neutral point (for example,
@@ -573,10 +590,11 @@ on-the-fly output codec:
the complete mapping so permissive `**kwargs` forwards do not receive unrelated base keys.
The dataset remains responsible only for strict V2 parsing and CPU media decoding. The adapter
-owns numerical output semantics. The SFT/offline-DPO trainer calls `encode_output_state()` on every
-microbatch under `torch.no_grad`; target, chosen, and rejected latents are not preprocessing-cache
-columns. The declared output components are loaded through `ModelLoadCoordinator`, never from
-inside the codec.
+owns numerical condition/output semantics. The SFT/offline-DPO trainer first calls
+`prepare_condition_state()` once, then calls `encode_output_state()` under `torch.no_grad`; offline
+DPO passes the same prepared object to both preference candidates. Target, chosen, and rejected
+latents are not preprocessing-cache columns. Declared condition and output components are loaded
+through `ModelLoadCoordinator`, never from inside a preparer or codec.
Condition encoding and target encoding should share role-neutral numerical transforms instead of
duplicating VAE math. Extract helpers for pixel preprocessing, posterior extraction, latent
@@ -605,6 +623,11 @@ multi-component state order, active masks, rate alignment, or forward context th
encoding does not own. If those semantics are not lossless, set a concrete
`output_state_codec_unavailable_reason` so offline selection fails before downloading weights.
+Multi-modal objectives may need a reduction different from trajectory likelihoods. Override the
+protected `_reduce_flow_matching_objective_values()` hook only for that objective. Do not change
+`reduce_latent_values()` merely to implement SFT: online policy gradients, replay log-probability,
+and distillation continue to rely on their established trajectory-wide reduction.
+
SenseNova is an example of an important boundary: its existing condition schema uses grouped
`images` with within-type order. Do not advertise heterogeneous ordered references merely because
several images are accepted. The public V2 discriminator remains `type`; conversion to a legacy
@@ -901,7 +924,10 @@ Before submitting a new model adapter, verify:
- [ ] **`inference()`** — Accepts both raw and pre-encoded inputs; returns `List[Sample]`
- [ ] **`forward()`** — Single denoising step; ends with `self.scheduler.step()`; returns `SDESchedulerOutput`
- [ ] **Pipeline I/O contract** — Declares exact input/output media, rate, geometry, and batch semantics before enabling offline training
+- [ ] **Effective checkpoint contract (when needed)** — `_resolve_pipeline_io_contract()` narrows a class-level superset without changing public dataset or algorithm code
+- [ ] **Condition-state preparer (when needed)** — Declaration-only logical component requirements; one input realization reused by every candidate/forward in the batch
- [ ] **Output-state codec (when supported)** — Declaration-only logical component requirements; on-the-fly detached target encoding; exact geometry validation
+- [ ] **Objective reduction (when specialized)** — Override only the offline flow-matching hook; online trajectory reduction remains unchanged
- [ ] **Offline forward overrides (when supported)** — Complete immutable adapter mapping; sampling controls never define offline loss semantics; every CFG branch is neutralized or every distilled guidance condition is explicitly pinned
- [ ] **Role-neutral encoder math** — Condition/output paths reuse transforms but explicitly preserve official posterior `sample` versus `argmax` policy and generator routing
- [ ] **Explicit offline blocker (when unsupported)** — `output_state_codec_unavailable_reason` names the missing lossless semantic boundary
diff --git a/guidance/workflow.md b/guidance/workflow.md
index 7af9c133b..491eb3d14 100644
--- a/guidance/workflow.md
+++ b/guidance/workflow.md
@@ -139,8 +139,8 @@ def preprocess_func(self, prompt, images, ...):
- **No HF default-cache copy**: Because each `map()` call sets `cache_file_name`, HuggingFace does **not** also write a duplicate `cache-*.arrow` under `~/.cache/huggingface/datasets/...`.
- **Intelligent caching**: A hash fingerprint of `(dataset, split, max_dataset_size, preprocess_func source, preprocess_kwargs, extra_hash_strs)` (the last includes `model_type` and `model_name_or_path`) determines the cache path. Subsequent runs that match the fingerprint take the fast path without any `Dataset.map` invocation.
- **Component offloading**: Text and condition encoders can be offloaded after cache creation. An
- offline adapter's declared output codec components are reloaded through the component lifecycle
- for on-the-fly target encoding.
+ offline adapter's declared runtime condition-preparer and output-codec components are reloaded
+ through the component lifecycle for on-the-fly condition realization and target encoding.
- **No target cache**: SFT targets and offline-DPO chosen/rejected candidates are decoded per
dataset access and encoded per training microbatch. Target payloads, latent states, and metadata
never enter the Arrow condition cache.
@@ -171,10 +171,15 @@ sampler.set_epoch(data_epoch)
for batch in dataloader:
condition = cached prompt/input tensors
output = freshly decoded target or chosen/rejected media
- trainer.optimize_batch(batch)
+ trainer.optimize_batch(batch) # prepares condition once inside the microbatch
data_epoch += 1 # only after clean exhaustion
```
+Inside `optimize_batch`, the trainer calls
+`adapter.prepare_condition_state(condition)` exactly once, binds every target
+candidate through that prepared object, and reuses its model-forward view for all
+policy/reference passes. The driver must not prepare it a second time.
+
One complete dataloader traversal is one offline epoch. Source weights must be `1`,
`data.sampler_type` remains `auto`, and `gradient_accumulation_steps` is an explicit integer. The
rank-local batch count must be divisible by gradient accumulation; the framework does not add
@@ -202,15 +207,26 @@ evaluation adapters or rewards may consume global RNG before the next training a
cannot currently save exact state because Accelerate does not serialize MPS RNG; use
`log.save_model_only: true` on Apple Silicon.
+The adapter prepares an input-owned condition state exactly once per offline batch. An identity
+preparer returns cached fields unchanged. A conditioned model may instead realize geometry-bound
+VAE latents, masks, or stochastic prefixes and split them into model-forward and output-codec
+contexts. SFT reuses that state for its target; offline DPO reuses the same object for chosen and
+rejected encoding and for both policy/reference arms. Candidate-specific output context is bound
+only after this input state exists, so neither candidate can accidentally own or redraw an input
+condition.
+
The target codec is adapter-owned but role-neutral at its numerical core. Condition and output
encoding reuse the same pixel preprocessing, VAE transform, normalization, and packing helpers.
Their semantic policies remain explicit: official condition paths commonly use posterior
-`argmax`, while stochastic target training uses posterior `sample` with an optional generator.
-Sharing a transform must never silently erase that role boundary.
-
-For offline DPO, chosen and rejected arms share the primary timestep, component-time mapping, and
-diffusion noise. Both policy arms run before one frozen-reference scope covers both reference
-forwards. SFT has no reference branch.
+`argmax`, while stochastic target training may use posterior `sample` with an optional generator.
+Sharing a transform must never silently erase that role boundary. Target media remains on demand;
+the prepared-condition boundary does not introduce a target-pixel or target-latent cache.
+
+For offline DPO, chosen and rejected arms share one prepared input state, the primary timestep,
+component-time mapping, and diffusion noise. Both policy arms run before one frozen-reference
+scope covers both reference forwards. SFT has no reference branch. Multi-component adapters may
+specialize only the offline flow-matching objective reduction (for example, a sum of per-modality
+means) without changing the trajectory-wide reducer used by online policy likelihoods.
## Stage 2: K-Repeat Sampling
diff --git a/src/flow_factory/contracts/__init__.py b/src/flow_factory/contracts/__init__.py
index 1da8d56d5..a77326a04 100644
--- a/src/flow_factory/contracts/__init__.py
+++ b/src/flow_factory/contracts/__init__.py
@@ -47,6 +47,7 @@
OutputMediaSequence,
PipelineIOContract,
RateRequirement,
+ resolve_pipeline_input_media_slots,
validate_pipeline_model_input,
validate_pipeline_output_candidate,
)
@@ -78,6 +79,7 @@
"OutputMediaSequence",
"PipelineIOContract",
"RateRequirement",
+ "resolve_pipeline_input_media_slots",
"ROLLOUT_STORAGE_KEYS",
"TRAINER_METADATA_KEYS",
"validate_pipeline_model_input",
diff --git a/src/flow_factory/contracts/pipeline_io.py b/src/flow_factory/contracts/pipeline_io.py
index bbc15316e..7e0006148 100644
--- a/src/flow_factory/contracts/pipeline_io.py
+++ b/src/flow_factory/contracts/pipeline_io.py
@@ -198,16 +198,20 @@ def __post_init__(self) -> None:
@dataclass(frozen=True, slots=True)
class InputMediaRule:
- """Declare the accepted count for one input media format."""
+ """Declare count and optional semantic slots for one input media format."""
format: MediaFormat
min_count: int
max_count: int | None
+ slots: tuple[str, ...] = ()
+ required_slots: tuple[str, ...] = ()
def __post_init__(self) -> None:
"""Validate strict cardinality types and bounds."""
_require_instance(self.format, MediaFormat, "format")
_require_non_negative_int(self.min_count, "min_count")
+ _require_string_tuple(self.slots, "slots")
+ _require_string_tuple(self.required_slots, "required_slots")
if self.max_count is not None:
_require_non_negative_int(self.max_count, "max_count")
if self.max_count == 0:
@@ -217,6 +221,37 @@ def __post_init__(self) -> None:
f"expected max_count >= min_count, received "
f"min_count={self.min_count} and max_count={self.max_count}"
)
+ if len(set(self.slots)) != len(self.slots):
+ raise ValueError("input media slots must be unique within one media rule")
+ if len(set(self.required_slots)) != len(self.required_slots):
+ raise ValueError("required input media slots must be unique")
+ unknown_required_slots = tuple(
+ slot for slot in self.required_slots if slot not in self.slots
+ )
+ if unknown_required_slots:
+ raise ValueError(
+ "required input media slots must be declared in slots; "
+ f"unknown={unknown_required_slots!r}"
+ )
+ canonical_required_slots = tuple(slot for slot in self.slots if slot in self.required_slots)
+ if self.required_slots != canonical_required_slots:
+ raise ValueError(
+ "required input media slots must use declared slot order "
+ f"{canonical_required_slots!r}, received {self.required_slots!r}"
+ )
+ if self.slots:
+ if self.max_count != len(self.slots):
+ raise ValueError(
+ "a slotted input media rule requires max_count to equal the number "
+ f"of slots, received max_count={self.max_count!r}, slots={self.slots!r}"
+ )
+ if len(self.required_slots) > self.min_count:
+ raise ValueError(
+ "required slot count cannot exceed min_count, received "
+ f"required_slots={self.required_slots!r}, min_count={self.min_count}"
+ )
+ elif self.required_slots:
+ raise ValueError("required input media slots cannot be declared without slots")
@dataclass(frozen=True, slots=True)
@@ -226,12 +261,44 @@ class InputMediaSpec:
rules: tuple[InputMediaRule, ...]
binding: InputMediaBinding
order: InputMediaOrder
+ min_total_count: int | None = None
+ max_total_count: int | None = None
+ required_any_types: tuple[MediaType, ...] = ()
def __post_init__(self) -> None:
"""Validate a canonical and coherent input-media declaration."""
_require_tuple(self.rules, InputMediaRule, "rules")
_require_enum(self.binding, InputMediaBinding, "binding")
_require_enum(self.order, InputMediaOrder, "order")
+ if self.min_total_count is not None:
+ _require_non_negative_int(self.min_total_count, "min_total_count")
+ if self.max_total_count is not None:
+ _require_non_negative_int(self.max_total_count, "max_total_count")
+ if self.max_total_count == 0:
+ raise ValueError(
+ "max_total_count=0 is not canonical; omit all input media rules instead"
+ )
+ if (
+ self.min_total_count is not None
+ and self.max_total_count is not None
+ and self.max_total_count < self.min_total_count
+ ):
+ raise ValueError(
+ "expected max_total_count >= min_total_count, received "
+ f"min_total_count={self.min_total_count} and "
+ f"max_total_count={self.max_total_count}"
+ )
+ _require_enum_tuple(self.required_any_types, MediaType, "required_any_types")
+ if len(set(self.required_any_types)) != len(self.required_any_types):
+ raise ValueError("required_any_types must contain each media type at most once")
+ canonical_required_any_types = tuple(
+ media_type for media_type in MediaType if media_type in self.required_any_types
+ )
+ if self.required_any_types != canonical_required_any_types:
+ raise ValueError(
+ "required_any_types must use canonical type order "
+ f"{canonical_required_any_types}, received {self.required_any_types}"
+ )
media_types = tuple(rule.format.type for rule in self.rules)
if len(set(media_types)) != len(media_types):
@@ -244,7 +311,59 @@ def __post_init__(self) -> None:
"input media rules must use canonical type order "
f"{canonical_media_types}, received {media_types}"
)
+ declared_slots = tuple(slot for rule in self.rules for slot in rule.slots)
+ if len(set(declared_slots)) != len(declared_slots):
+ raise ValueError("input media slot names must be unique across media rules")
+ if declared_slots and self.binding is not InputMediaBinding.GROUPED_BY_TYPE:
+ raise ValueError("semantic input media slots require grouped_by_type binding")
+ if (
+ any(len(rule.slots) > 1 for rule in self.rules)
+ and self.order is not InputMediaOrder.WITHIN_TYPE
+ ):
+ raise ValueError(
+ "multi-slot input media rules require within_type ordering because "
+ "unslotted media uses positional fallback"
+ )
+ unknown_required_types = tuple(
+ media_type for media_type in self.required_any_types if media_type not in media_types
+ )
+ if unknown_required_types:
+ raise ValueError(
+ "required_any_types must be declared by input media rules; "
+ f"unknown={unknown_required_types!r}"
+ )
+ minimum_from_rules = sum(rule.min_count for rule in self.rules)
+ if (
+ self.rules
+ and self.max_total_count is not None
+ and self.max_total_count < minimum_from_rules
+ ):
+ raise ValueError(
+ "max_total_count cannot be smaller than the sum of per-type minimums, "
+ f"received max_total_count={self.max_total_count}, "
+ f"per_type_minimum={minimum_from_rules}"
+ )
+ if (
+ self.rules
+ and self.min_total_count is not None
+ and all(rule.max_count is not None for rule in self.rules)
+ ):
+ maximum_from_rules = sum(
+ rule.max_count for rule in self.rules if rule.max_count is not None
+ )
+ if self.min_total_count > maximum_from_rules:
+ raise ValueError(
+ "min_total_count cannot exceed the sum of finite per-type maximums, "
+ f"received min_total_count={self.min_total_count}, "
+ f"per_type_maximum={maximum_from_rules}"
+ )
if not self.rules:
+ if (
+ self.min_total_count is not None
+ or self.max_total_count is not None
+ or self.required_any_types
+ ):
+ raise ValueError("media-free inputs cannot declare aggregate media constraints")
if self.binding is not InputMediaBinding.GROUPED_BY_TYPE:
raise ValueError("media-free inputs must use grouped_by_type binding")
if self.order is not InputMediaOrder.INSENSITIVE:
@@ -287,11 +406,15 @@ def __post_init__(self) -> None:
_require_instance(self.output_media, OutputMediaSequence, "output_media")
_require_enum(self.geometry_source, GeometrySource, "geometry_source")
_require_enum(self.batch_capability, BatchCapability, "batch_capability")
- if self.geometry_source is GeometrySource.INPUT_MEDIA and not any(
- rule.min_count > 0 for rule in self.input_media.rules
- ):
+ guarantees_input_media = (
+ any(rule.min_count > 0 for rule in self.input_media.rules)
+ or bool(self.input_media.min_total_count)
+ or bool(self.input_media.required_any_types)
+ )
+ if self.geometry_source is GeometrySource.INPUT_MEDIA and not guarantees_input_media:
raise ValueError(
- "input_media geometry requires at least one input media rule with min_count > 0"
+ "input_media geometry requires input constraints that guarantee at least "
+ "one media item"
)
@@ -385,6 +508,98 @@ def validate_pipeline_model_input(
f"received {count}"
)
+ total_count = len(media)
+ input_media = contract.input_media
+ if input_media.min_total_count is not None and total_count < input_media.min_total_count:
+ raise ValueError(
+ f"pipeline requires at least {input_media.min_total_count} input media item(s) "
+ f"in total, received {total_count}"
+ )
+ if input_media.max_total_count is not None and total_count > input_media.max_total_count:
+ raise ValueError(
+ f"pipeline accepts at most {input_media.max_total_count} input media item(s) "
+ f"in total, received {total_count}"
+ )
+ if input_media.required_any_types and not any(
+ counts[media_type.value] > 0 for media_type in input_media.required_any_types
+ ):
+ accepted = tuple(media_type.value for media_type in input_media.required_any_types)
+ raise ValueError(
+ "pipeline requires at least one input media item whose type is in " f"{accepted!r}"
+ )
+ _resolve_pipeline_input_media_slots_unchecked(media, contract)
+
+
+def resolve_pipeline_input_media_slots(
+ model_input: ModelInputLike,
+ contract: PipelineIOContract,
+) -> tuple[str | None, ...]:
+ """Resolve explicit and positional input media onto adapter-declared slots.
+
+ Explicit slot values reserve their named positions first. Unslotted media
+ then fill the remaining positions in declaration order, preserving the V2
+ positional shorthand while making sparse bindings such as a last-frame-only
+ request unambiguous.
+ """
+ validate_pipeline_model_input(model_input, contract)
+ return _resolve_pipeline_input_media_slots_unchecked(model_input.media, contract)
+
+
+def _resolve_pipeline_input_media_slots_unchecked(
+ media: tuple[InputMediaLike, ...],
+ contract: PipelineIOContract,
+) -> tuple[str | None, ...]:
+ assignments: list[str | None] = [None] * len(media)
+ rules_by_type = {rule.format.type.value: rule for rule in contract.input_media.rules}
+ for media_type, rule in rules_by_type.items():
+ indices = [index for index, item in enumerate(media) if item.type == media_type]
+ if not rule.slots:
+ for index in indices:
+ slot = getattr(media[index], "slot", None)
+ if slot is not None:
+ raise ValueError(
+ f"pipeline input media[{index}] declares slot={slot!r}, but "
+ f"media type {media_type!r} has no semantic slots"
+ )
+ continue
+
+ claimed: dict[str, int] = {}
+ unassigned_indices = []
+ for index in indices:
+ slot = getattr(media[index], "slot", None)
+ if slot is None:
+ unassigned_indices.append(index)
+ continue
+ if type(slot) is not str:
+ raise TypeError(
+ f"expected model_input.media[{index}].slot to be str or None, "
+ f"received {type(slot).__name__}: {slot!r}"
+ )
+ if slot not in rule.slots:
+ raise ValueError(
+ f"pipeline input media[{index}] slot {slot!r} is not accepted for "
+ f"media type {media_type!r}; accepted slots={rule.slots!r}"
+ )
+ if slot in claimed:
+ raise ValueError(
+ f"pipeline input media slot {slot!r} is assigned more than once at "
+ f"indices {claimed[slot]} and {index}"
+ )
+ assignments[index] = slot
+ claimed[slot] = index
+
+ remaining_slots = [slot for slot in rule.slots if slot not in claimed]
+ for index, slot in zip(unassigned_indices, remaining_slots):
+ assignments[index] = slot
+ claimed[slot] = index
+ missing_required_slots = tuple(slot for slot in rule.required_slots if slot not in claimed)
+ if missing_required_slots:
+ raise ValueError(
+ f"pipeline requires input media slots {missing_required_slots!r} for "
+ f"media type {media_type!r}"
+ )
+ return tuple(assignments)
+
def validate_pipeline_output_candidate(
media: tuple[OutputMediaLike, ...],
@@ -425,6 +640,11 @@ def validate_pipeline_output_candidate(
f"expected output candidate media[{index}] to implement OutputMediaLike, "
f"received {type(item).__name__}: {item!r}"
)
+ slot = getattr(item, "slot", None)
+ if slot is not None:
+ raise ValueError(
+ f"output candidate media[{index}] cannot declare input-only slot={slot!r}"
+ )
media_type = item.type
if type(media_type) is not str:
raise TypeError(
@@ -526,6 +746,34 @@ def _require_tuple(value: object, item_type: type[object], field_name: str) -> N
_require_instance(item, item_type, f"{field_name}[{index}]")
+def _require_string_tuple(value: object, field_name: str) -> None:
+ if type(value) is not tuple:
+ raise TypeError(
+ f"expected {field_name} to be tuple, received {type(value).__name__}: {value!r}"
+ )
+ for index, item in enumerate(value):
+ if type(item) is not str:
+ raise TypeError(
+ f"expected {field_name}[{index}] to be str, received "
+ f"{type(item).__name__}: {item!r}"
+ )
+ if not item.strip():
+ raise ValueError(f"expected {field_name}[{index}] to be a non-empty string")
+
+
+def _require_enum_tuple(
+ value: object,
+ enum_type: type[Enum],
+ field_name: str,
+) -> None:
+ if type(value) is not tuple:
+ raise TypeError(
+ f"expected {field_name} to be tuple, received {type(value).__name__}: {value!r}"
+ )
+ for index, item in enumerate(value):
+ _require_enum(item, enum_type, f"{field_name}[{index}]")
+
+
def _require_non_negative_int(value: object, field_name: str) -> None:
if type(value) is not int:
raise TypeError(
@@ -552,6 +800,7 @@ def _require_non_negative_int(value: object, field_name: str) -> None:
"OutputMediaLike",
"PipelineIOContract",
"RateRequirement",
+ "resolve_pipeline_input_media_slots",
"validate_pipeline_model_input",
"validate_pipeline_output_candidate",
]
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index 2a145cfd4..f7af6eccd 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -350,6 +350,21 @@ def _preprocess_dataset(
source_hash_override=source_hash_override,
)
+ # Every distributed rank must write the same Arrow schema. Infer it from
+ # the unsharded source before selecting the rank-local rows; otherwise an
+ # all-empty optional-media shard can become ``List(null)`` while another
+ # rank writes ``List(Image)`` and the merged cache cannot be loaded.
+ preprocess_features = self._infer_cross_chunk_features(
+ raw_dataset=raw_dataset,
+ preprocessing_batch_size=preprocessing_batch_size,
+ image_dir=self.image_dir,
+ video_dir=self.video_dir,
+ audio_dir=self.audio_dir,
+ force_reprocess=force_reprocess,
+ target_arrow_path=target_arrow_path,
+ require_explicit_features=bool(self.num_shards and self.num_shards > 1),
+ )
+
if self.num_shards and self.num_shards > 1:
if self.shard_index is None:
raise ValueError(
@@ -375,16 +390,6 @@ def _preprocess_dataset(
if target_arrow_path is not None:
os.makedirs(os.path.dirname(os.path.abspath(target_arrow_path)), exist_ok=True)
- preprocess_features = self._infer_cross_chunk_features(
- raw_dataset=raw_dataset,
- preprocessing_batch_size=preprocessing_batch_size,
- image_dir=self.image_dir,
- video_dir=self.video_dir,
- audio_dir=self.audio_dir,
- force_reprocess=force_reprocess,
- target_arrow_path=target_arrow_path,
- )
-
processed_dataset = raw_dataset.map(
self._preprocess_batch,
batched=True,
@@ -417,6 +422,7 @@ def _infer_cross_chunk_features(
audio_dir: Optional[str],
force_reprocess: bool,
target_arrow_path: Optional[str],
+ require_explicit_features: bool = False,
) -> Optional[HFFeatures]:
"""Infer one explicit schema when later map chunks introduce typed values.
@@ -429,13 +435,16 @@ def _infer_cross_chunk_features(
adapter-owned mutable state is intentionally outside that guarantee.
Args:
- raw_dataset: Input dataset after any distributed sharding.
+ raw_dataset: Full input dataset before distributed sharding.
preprocessing_batch_size: Map chunk size.
image_dir: Image root forwarded to preprocessing.
video_dir: Video root forwarded to preprocessing.
audio_dir: Audio root forwarded to preprocessing.
force_reprocess: Whether an existing explicit Arrow target is rebuilt.
target_arrow_path: Optional explicit Arrow cache target.
+ require_explicit_features: Whether to infer a schema even when the
+ first source chunk is already representative. Distributed ranks
+ use this to share one global schema across disjoint shards.
Returns:
Explicit output features for a cross-chunk nullable transition, or
@@ -453,7 +462,9 @@ def _infer_cross_chunk_features(
preprocessing_batch_size=preprocessing_batch_size,
)
if probe_batches is None:
- return None
+ if not require_explicit_features or not len(raw_dataset):
+ return None
+ probe_batches = [list(range(min(preprocessing_batch_size, len(raw_dataset))))]
explicit_generators = list(_iter_torch_generators(self._preprocess_kwargs))
generator_states = [generator.get_state() for generator in explicit_generators]
@@ -581,7 +592,16 @@ def _preprocess_batch(
f"received B={len(batch['prompt'])} for split={self.split!r}"
)
# The columns that are used in preprocess and maintained in the final results.
- PREPROCESS_COLUMNS = ("prompt", "negative_prompt", "images", "videos", "audios")
+ PREPROCESS_COLUMNS = (
+ "prompt",
+ "negative_prompt",
+ "images",
+ "videos",
+ "audios",
+ "image_slots",
+ "video_slots",
+ "audio_slots",
+ )
metadata_excluded_columns = set(PREPROCESS_COLUMNS)
metadata_excluded_columns.update(self._passthrough_columns)
if self._uses_ordered_references:
@@ -703,6 +723,12 @@ def _preprocess_batch(
reference_args["references"] = loaded_reference_batch
batch["reference_manifest"] = canonical_manifests
+ slot_args = {
+ column: batch[column]
+ for column in ("image_slots", "video_slots", "audio_slots")
+ if column in batch
+ }
+
# 5. Call preprocess function with filtered kwargs
input_args = {
**prompt_args,
@@ -710,6 +736,7 @@ def _preprocess_batch(
**video_args,
**audio_args,
**reference_args,
+ **slot_args,
**self._preprocess_kwargs,
}
filtered_args = filter_kwargs(self._preprocess_func, **input_args)
diff --git a/src/flow_factory/data_utils/offline_condition_cache.py b/src/flow_factory/data_utils/offline_condition_cache.py
index 4d0ea3dcf..94b85d4e6 100644
--- a/src/flow_factory/data_utils/offline_condition_cache.py
+++ b/src/flow_factory/data_utils/offline_condition_cache.py
@@ -30,6 +30,13 @@
from datasets import Dataset as HFDataset
+from ..contracts import (
+ BatchCapability,
+ InputMediaBinding,
+ NegativePromptPolicy,
+ PipelineIOContract,
+ resolve_pipeline_input_media_slots,
+)
from ..samples.references import canonicalize_reference_manifest
from .dataset import (
METADATA_COLUMN,
@@ -43,7 +50,7 @@
)
from .schema import MediaAsset, NormalizedDatasetRecord
-_CONDITION_SOURCE_FORMAT = "flow-factory-offline-condition-v1"
+_CONDITION_SOURCE_FORMAT = "flow-factory-offline-condition-v3"
def project_offline_condition_dataset(
@@ -51,6 +58,7 @@ def project_offline_condition_dataset(
*,
source_name: str,
ordered_references: bool,
+ pipeline_io_contract: PipelineIOContract | None = None,
_media_digest_cache: MutableMapping[str, str] | None = None,
) -> HFDataset:
"""Build an input-only raw dataset for ``GeneralDataset`` preprocessing.
@@ -65,6 +73,23 @@ def project_offline_condition_dataset(
raise TypeError(
"ordered_references must be a bool, " f"got {type(ordered_references).__name__}"
)
+ if pipeline_io_contract is not None and not isinstance(
+ pipeline_io_contract, PipelineIOContract
+ ):
+ raise TypeError(
+ "pipeline_io_contract must be a PipelineIOContract or None, "
+ f"got {type(pipeline_io_contract).__name__}"
+ )
+ if pipeline_io_contract is not None:
+ expected_ordered_references = (
+ pipeline_io_contract.input_media.binding is InputMediaBinding.ORDERED_REFERENCES
+ )
+ if ordered_references != expected_ordered_references:
+ raise ValueError(
+ "offline condition projection binding disagrees with pipeline contract: "
+ f"ordered_references={ordered_references}, "
+ f"contract={pipeline_io_contract.input_media.binding.value!r}"
+ )
stable_records = tuple(records)
if not stable_records:
raise ValueError("offline condition projection requires at least one record")
@@ -91,9 +116,28 @@ def project_offline_condition_dataset(
"prompt": [record.model_input.prompt for record in stable_records],
OFFLINE_CONDITION_ID_COLUMN: condition_ids,
}
+ resolved_slots = []
+ for record in stable_records:
+ if pipeline_io_contract is None:
+ slots = tuple(None for _ in record.model_input.media)
+ if any(asset.slot is not None for asset in record.model_input.media):
+ raise ValueError(
+ "offline condition projection requires pipeline_io_contract when "
+ "input media declares semantic slots"
+ )
+ else:
+ slots = resolve_pipeline_input_media_slots(
+ record.model_input,
+ pipeline_io_contract,
+ )
+ resolved_slots.append(slots)
negative_prompts = [record.model_input.negative_prompt for record in stable_records]
- if any(value is not None for value in negative_prompts):
+ projects_negative_prompt = (
+ pipeline_io_contract is not None
+ and pipeline_io_contract.negative_prompt is not NegativePromptPolicy.UNSUPPORTED
+ )
+ if projects_negative_prompt or any(value is not None for value in negative_prompts):
# Adapter tokenizers consume a homogeneous text batch. In a mixed V2
# batch, an omitted optional negative prompt is semantically the empty
# prompt, not a tokenizer-level ``None`` value.
@@ -110,37 +154,60 @@ def project_offline_condition_dataset(
for index, record in enumerate(stable_records)
]
else:
+ grouped_rows = [
+ _group_media_by_type_and_slot(
+ record.model_input.media,
+ slots,
+ pipeline_io_contract,
+ )
+ for record, slots in zip(stable_records, resolved_slots)
+ ]
grouped_columns = {
- "images": [
- [asset.path for asset in record.model_input.media if asset.type == "image"]
- for record in stable_records
- ],
+ "images": [[asset.path for asset, _ in row["image"]] for row in grouped_rows],
"videos": [
- [
- _to_grouped_rate_spec(asset, rate_name="fps")
- for asset in record.model_input.media
- if asset.type == "video"
- ]
- for record in stable_records
+ [_to_grouped_rate_spec(asset, rate_name="fps") for asset, _ in row["video"]]
+ for row in grouped_rows
],
"audios": [
- [
- _to_grouped_rate_spec(asset, rate_name="sample_rate")
- for asset in record.model_input.media
- if asset.type == "audio"
- ]
- for record in stable_records
+ [_to_grouped_rate_spec(asset, rate_name="sample_rate") for asset, _ in row["audio"]]
+ for row in grouped_rows
],
+ "image_slots": [[slot for _, slot in row["image"]] for row in grouped_rows],
+ "video_slots": [[slot for _, slot in row["video"]] for row in grouped_rows],
+ "audio_slots": [[slot for _, slot in row["audio"]] for row in grouped_rows],
}
- columns.update(
- {column_name: values for column_name, values in grouped_columns.items() if any(values)}
+ declared_rules = (
+ {}
+ if pipeline_io_contract is None
+ else {rule.format.type.value: rule for rule in pipeline_io_contract.input_media.rules}
)
+ column_media_types = {
+ "images": "image",
+ "videos": "video",
+ "audios": "audio",
+ "image_slots": "image",
+ "video_slots": "video",
+ "audio_slots": "audio",
+ }
+ for column_name, values in grouped_columns.items():
+ media_type = column_media_types[column_name]
+ rule = declared_rules.get(media_type)
+ if column_name.endswith("_slots"):
+ include = rule is not None and bool(rule.slots)
+ else:
+ include = rule is not None if pipeline_io_contract is not None else any(values)
+ if include:
+ columns[column_name] = values
return HFDataset.from_dict(columns)
-def compute_offline_condition_source_hash(condition_ids: Sequence[str]) -> str:
- """Hash ordered input identities for an input-only cache fingerprint."""
+def compute_offline_condition_source_hash(
+ condition_ids: Sequence[str],
+ *,
+ pipeline_io_contract: PipelineIOContract | None = None,
+) -> str:
+ """Hash ordered inputs and their effective projection contract."""
stable_ids = tuple(condition_ids)
if not stable_ids:
raise ValueError("offline condition source hash requires at least one condition id")
@@ -150,10 +217,19 @@ def compute_offline_condition_source_hash(condition_ids: Sequence[str]) -> str:
"offline condition ids must be non-empty strings, "
f"got {condition_id!r} at index {index}"
)
+ if pipeline_io_contract is not None and not isinstance(
+ pipeline_io_contract,
+ PipelineIOContract,
+ ):
+ raise TypeError(
+ "pipeline_io_contract must be a PipelineIOContract or None, "
+ f"got {type(pipeline_io_contract).__name__}"
+ )
payload = json.dumps(
{
"format": _CONDITION_SOURCE_FORMAT,
"condition_ids": stable_ids,
+ "input_projection_contract": _input_projection_contract_identity(pipeline_io_contract),
},
ensure_ascii=False,
allow_nan=False,
@@ -169,6 +245,7 @@ def build_offline_condition_cache(
dataset_dir: str | os.PathLike[str],
preprocess_func: PreprocessCallable,
preprocess_kwargs: Mapping[str, Any] | None = None,
+ pipeline_io_contract: PipelineIOContract | None = None,
cache_dir: str | os.PathLike[str] = "~/.cache/flow_factory/datasets",
force_reprocess: bool = False,
preprocessing_batch_size: int | None = None,
@@ -193,16 +270,24 @@ def build_offline_condition_cache(
records,
source_name=source_name,
ordered_references=ordered_references,
+ pipeline_io_contract=pipeline_io_contract,
)
condition_ids = tuple(raw_dataset[OFFLINE_CONDITION_ID_COLUMN])
- source_hash = compute_offline_condition_source_hash(condition_ids)
+ source_hash = compute_offline_condition_source_hash(
+ condition_ids,
+ pipeline_io_contract=pipeline_io_contract,
+ )
normalized_dataset_dir = os.path.expanduser(os.fspath(dataset_dir))
normalized_cache_dir = os.path.expanduser(os.fspath(cache_dir))
normalized_preprocess_kwargs = dict(preprocess_kwargs or {})
normalized_extra_hash_strs = list(extra_hash_strs or ())
+ requires_single_sample_batches = ordered_references or (
+ pipeline_io_contract is not None
+ and pipeline_io_contract.batch_capability is BatchCapability.SINGLE_SAMPLE
+ )
if preprocessing_batch_size is None:
- preprocessing_batch_size = 1 if ordered_references else 16
+ preprocessing_batch_size = 1 if requires_single_sample_batches else 16
if (
not isinstance(preprocessing_batch_size, int)
or isinstance(preprocessing_batch_size, bool)
@@ -212,6 +297,8 @@ def build_offline_condition_cache(
"preprocessing_batch_size must be a positive integer, "
f"got {preprocessing_batch_size!r}"
)
+ if requires_single_sample_batches:
+ preprocessing_batch_size = 1
normalized_target_arrow_path: str
if target_arrow_path is None:
@@ -261,6 +348,36 @@ def _to_legacy_reference(asset: MediaAsset) -> Dict[str, Any]:
return reference
+def _input_projection_contract_identity(
+ contract: PipelineIOContract | None,
+) -> Dict[str, Any] | None:
+ """Return the canonical contract fields that can alter input preprocessing."""
+ if contract is None:
+ return None
+ input_media = contract.input_media
+ return {
+ "binding": input_media.binding.value,
+ "order": input_media.order.value,
+ "min_total_count": input_media.min_total_count,
+ "max_total_count": input_media.max_total_count,
+ "required_any_types": [value.value for value in input_media.required_any_types],
+ "rules": [
+ {
+ "type": rule.format.type.value,
+ "fps": rule.format.fps.value,
+ "sample_rate": rule.format.sample_rate.value,
+ "min_count": rule.min_count,
+ "max_count": rule.max_count,
+ "slots": list(rule.slots),
+ "required_slots": list(rule.required_slots),
+ }
+ for rule in input_media.rules
+ ],
+ "negative_prompt": contract.negative_prompt.value,
+ "batch_capability": contract.batch_capability.value,
+ }
+
+
def _to_grouped_rate_spec(asset: MediaAsset, *, rate_name: str) -> Dict[str, Any]:
spec: Dict[str, Any] = {"path": asset.path}
rate = getattr(asset, rate_name)
@@ -269,6 +386,29 @@ def _to_grouped_rate_spec(asset: MediaAsset, *, rate_name: str) -> Dict[str, Any
return spec
+def _group_media_by_type_and_slot(
+ media: Sequence[MediaAsset],
+ slots: Sequence[str | None],
+ contract: PipelineIOContract | None,
+) -> Dict[str, List[tuple[MediaAsset, str | None]]]:
+ """Group one record and canonicalize slotted media into declaration order."""
+ grouped: Dict[str, List[tuple[MediaAsset, str | None]]] = {
+ "image": [],
+ "video": [],
+ "audio": [],
+ }
+ for asset, slot in zip(media, slots):
+ grouped[asset.type].append((asset, slot))
+ if contract is None:
+ return grouped
+ for rule in contract.input_media.rules:
+ if not rule.slots:
+ continue
+ slot_order = {slot: index for index, slot in enumerate(rule.slots)}
+ grouped[rule.format.type.value].sort(key=lambda item: slot_order[item[1]])
+ return grouped
+
+
__all__ = [
"build_offline_condition_cache",
"compute_offline_condition_source_hash",
diff --git a/src/flow_factory/data_utils/offline_dataset.py b/src/flow_factory/data_utils/offline_dataset.py
index 9043a15bc..767257ffd 100644
--- a/src/flow_factory/data_utils/offline_dataset.py
+++ b/src/flow_factory/data_utils/offline_dataset.py
@@ -700,6 +700,7 @@ def _media_identity(
"path": media.path,
"fps": media.fps,
"sample_rate": media.sample_rate,
+ "slot": media.slot,
"content_sha256": _media_content_sha256(
media.path,
media_digest_cache=media_digest_cache,
diff --git a/src/flow_factory/data_utils/offline_train_data.py b/src/flow_factory/data_utils/offline_train_data.py
index 4fc16575a..f2b66dfd5 100644
--- a/src/flow_factory/data_utils/offline_train_data.py
+++ b/src/flow_factory/data_utils/offline_train_data.py
@@ -223,7 +223,7 @@ def build_offline_train_dataloader(
preprocess_func=preprocess_func,
preprocess_kwargs=normalized_preprocess_kwargs,
preprocessing_batch_size=data_args.preprocessing_batch_size,
- batch_capability=pipeline_io_contract.batch_capability,
+ pipeline_io_contract=pipeline_io_contract,
force_reprocess=data_args.force_reprocess,
extra_hash_strs=[*normalized_extra_hash_strs, f"offline_train_source:{source.name}"],
preprocess_parallelism=data_args.preprocess_parallelism,
@@ -315,7 +315,7 @@ def _build_distributed_condition_cache(
preprocess_func: PreprocessCallable,
preprocess_kwargs: Mapping[str, Any],
preprocessing_batch_size: int,
- batch_capability: BatchCapability,
+ pipeline_io_contract: PipelineIOContract,
force_reprocess: bool,
extra_hash_strs: Sequence[str],
preprocess_parallelism: Literal["global", "local"],
@@ -326,17 +326,22 @@ def _build_distributed_condition_cache(
ordered_references = _supports_ordered_references(preprocess_func)
effective_batch_size = (
1
- if ordered_references or batch_capability is BatchCapability.SINGLE_SAMPLE
+ if ordered_references
+ or pipeline_io_contract.batch_capability is BatchCapability.SINGLE_SAMPLE
else preprocessing_batch_size
)
raw_dataset = project_offline_condition_dataset(
records,
source_name=source_name,
ordered_references=ordered_references,
+ pipeline_io_contract=pipeline_io_contract,
_media_digest_cache=_media_digest_cache,
)
condition_ids = tuple(raw_dataset[OFFLINE_CONDITION_ID_COLUMN])
- source_hash = compute_offline_condition_source_hash(condition_ids)
+ source_hash = compute_offline_condition_source_hash(
+ condition_ids,
+ pipeline_io_contract=pipeline_io_contract,
+ )
dataset_builder = _create_or_load_dataset(
split=split,
accelerator=accelerator,
diff --git a/src/flow_factory/data_utils/schema.py b/src/flow_factory/data_utils/schema.py
index de80b12f4..941dd32b6 100644
--- a/src/flow_factory/data_utils/schema.py
+++ b/src/flow_factory/data_utils/schema.py
@@ -32,6 +32,7 @@
Field,
JsonValue,
field_validator,
+ model_validator,
)
MediaType = Literal["image", "video", "audio"]
@@ -52,6 +53,7 @@ class _MediaRefBase(_StrictFrozenModel):
"""Shared path contract for the exact-key media variants."""
path: str
+ slot: str | None = None
@field_validator("path")
@classmethod
@@ -60,6 +62,13 @@ def _validate_path(cls, value: str) -> str:
raise ValueError("media path must be a non-empty string")
return value
+ @field_validator("slot")
+ @classmethod
+ def _validate_slot(cls, value: str | None) -> str | None:
+ if value is not None and not value.strip():
+ raise ValueError("media slot must be a non-empty string when provided")
+ return value
+
class ImageRef(_MediaRefBase):
"""Image asset with no rate fields."""
@@ -100,6 +109,18 @@ class OutputCandidateSpec(_StrictFrozenModel):
media: List[MediaRef] = Field(min_length=1)
+ @model_validator(mode="after")
+ def _reject_input_only_slots(self) -> "OutputCandidateSpec":
+ slotted_indices = tuple(
+ index for index, media in enumerate(self.media) if media.slot is not None
+ )
+ if slotted_indices:
+ raise ValueError(
+ "media slot is input-only and cannot appear in an output candidate; "
+ f"indices={slotted_indices!r}"
+ )
+ return self
+
class DemonstrationSpec(_StrictFrozenModel):
"""A single supervised target without naming a training algorithm."""
@@ -139,6 +160,7 @@ class MediaAsset:
path: str
fps: float | None = None
sample_rate: int | None = None
+ slot: str | None = None
@dataclass(frozen=True, slots=True)
@@ -261,6 +283,7 @@ def _normalize_media(
path=path,
fps=media.fps if isinstance(media, VideoRef) else None,
sample_rate=media.sample_rate if isinstance(media, AudioRef) else None,
+ slot=media.slot,
)
diff --git a/src/flow_factory/models/__init__.py b/src/flow_factory/models/__init__.py
index 9dffe36b0..39e172aa6 100644
--- a/src/flow_factory/models/__init__.py
+++ b/src/flow_factory/models/__init__.py
@@ -21,6 +21,11 @@
"""
from .abc import BaseAdapter
+from .condition_state import (
+ ConditionStatePreparer,
+ PreparedConditionState,
+ validate_condition_preparer_required_components,
+)
from .latent_geometry import LatentAxes, LatentLayout, infer_latent_axes
from .loader import load_model
from .model_bundle import ModelBundle, RoutedComponentProxy
@@ -42,6 +47,10 @@
__all__ = [
# Core classes
"BaseAdapter",
+ # Runtime input-condition preparation
+ "ConditionStatePreparer",
+ "PreparedConditionState",
+ "validate_condition_preparer_required_components",
# Offline target encoding
"DecodedMediaBatch",
"EncodedOutputState",
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index 604e7276f..d110e78a1 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -110,6 +110,11 @@
select_gradient_checkpointing_units,
selective_gradient_checkpointing_function,
)
+from .condition_state import (
+ ConditionStatePreparer,
+ PreparedConditionState,
+ validate_condition_preparer_required_components,
+)
from .latent_geometry import LatentAxes, infer_latent_axes
from .model_bundle import RoutedComponentProxy
from .output_state import (
@@ -267,10 +272,12 @@ class BaseAdapter(ABC):
# name. Overriding one would silently bypass that contract, so subclasses are
# rejected at class creation instead of at training time.
_BOUNDARY_OWNING_METHODS: ClassVar[Tuple[str, ...]] = (
+ "prepare_condition_state",
"encode_output_state",
"decode_output_state",
"forward_state",
"reduce_component_latent_values",
+ "reduce_flow_matching_objective_values",
"reduce_latent_values",
)
@@ -283,6 +290,8 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
"Provide build_output_state_codec() and "
"_validate_encoded_output_geometry() instead."
)
+ elif name == "prepare_condition_state":
+ override_hint = "Provide build_condition_state_preparer() instead."
elif name == "decode_output_state":
override_hint = "Override the protected hook _decode_output_state instead."
else:
@@ -329,6 +338,17 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
"expected SchedulerGroup.primary to be the canonical pipeline scheduler, "
f"received primary component {self.scheduler_group.primary_name!r}"
)
+ self._effective_pipeline_io_contract = self._resolve_pipeline_io_contract()
+ if self._effective_pipeline_io_contract is not None and not isinstance(
+ self._effective_pipeline_io_contract,
+ PipelineIOContract,
+ ):
+ raise TypeError(
+ f"adapter {type(self).__name__} expected _resolve_pipeline_io_contract() "
+ "to return PipelineIOContract or None, received "
+ f"{type(self._effective_pipeline_io_contract).__name__}: "
+ f"{self._effective_pipeline_io_contract!r}"
+ )
# Compatibility alias: the runtime override mapping is the sole authoritative cache.
self._components: Dict[str, torch.nn.Module] = cast(
@@ -338,6 +358,12 @@ def __init__(self, config: Arguments, accelerator: Accelerator):
self.model_args.target_components
)
+ # Build per-request input-condition realization after the component runtime
+ # exists, but before any codec may consume its declaration. Like the output
+ # codec, the preparer declares lifecycle metadata only.
+ self._condition_state_preparer = self._build_condition_state_preparer_declaration()
+ self._condition_state_encoding_modules = self._validate_condition_state_preparer_lifecycle()
+
# Build target-media encoding only after load-dtype policy, component runtime,
# scheduler group, and target-name canonicalization are established. The codec
# declaration is immutable lifecycle metadata; it must not materialize, load,
@@ -455,6 +481,116 @@ def cast_latent_state(
return state
return LatentState(components, active_masks=state.active_masks)
+ # =========================== Condition-State Preparation =======================
+ @property
+ def effective_pipeline_io_contract(self) -> Optional[PipelineIOContract]:
+ """Return the checkpoint-realized pipeline input/output contract."""
+ return getattr(
+ self,
+ "_effective_pipeline_io_contract",
+ type(self).pipeline_io_contract,
+ )
+
+ def _resolve_pipeline_io_contract(self) -> Optional[PipelineIOContract]:
+ """Resolve checkpoint-specific capabilities from the class declaration.
+
+ Most adapters use one immutable class-level contract. An adapter wrapping
+ checkpoint variants with narrower input semantics may override this hook
+ and return a validated specialization after its pipeline config is known.
+
+ Returns:
+ Effective contract for this adapter instance, or ``None``.
+ """
+ return type(self).pipeline_io_contract
+
+ def _build_condition_state_preparer_declaration(
+ self,
+ ) -> Optional[ConditionStatePreparer]:
+ """Build preparer metadata without changing component runtime state."""
+ materialized_before = tuple(self.component_runtime.materialized_component_names)
+ overrides_before = tuple(self.component_runtime.override_components)
+ preparer = self.build_condition_state_preparer()
+ materialized_after = tuple(self.component_runtime.materialized_component_names)
+ overrides_after = tuple(self.component_runtime.override_components)
+ if materialized_after != materialized_before or overrides_after != overrides_before:
+ raise RuntimeError(
+ f"adapter {type(self).__name__}.build_condition_state_preparer() must be "
+ "declaration-only and cannot materialize or replace components: "
+ f"materialized_before={materialized_before}, "
+ f"materialized_after={materialized_after}, "
+ f"overrides_before={overrides_before}, overrides_after={overrides_after}"
+ )
+ return preparer
+
+ @property
+ def condition_state_preparer(self) -> Optional[ConditionStatePreparer]:
+ """Return the immutable preparer selected during adapter construction."""
+ return getattr(self, "_condition_state_preparer", None)
+
+ @property
+ def condition_state_encoding_modules(self) -> Tuple[str, ...]:
+ """Return validated component names required for condition realization."""
+ return self._condition_state_encoding_modules
+
+ def build_condition_state_preparer(self) -> Optional[ConditionStatePreparer]:
+ """Build the adapter-owned runtime condition preparer, if required.
+
+ The default identity path needs no declaration. A model whose input
+ condition depends on runtime geometry, stochastic augmentation, or an
+ on-device encoder returns a declaration-only preparer here.
+
+ Returns:
+ Adapter-owned preparer, or ``None`` for identity preparation.
+ """
+ return None
+
+ def _validate_condition_state_preparer_lifecycle(self) -> Tuple[str, ...]:
+ """Validate condition-preparer lifecycle metadata."""
+ preparer = self.condition_state_preparer
+ if preparer is None:
+ return ()
+ return validate_condition_preparer_required_components(
+ preparer,
+ tuple(self.component_runtime.declared_component_names),
+ )
+
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> PreparedConditionState:
+ """Realize one input-owned condition state for a request or batch.
+
+ Args:
+ condition: Cached input-only model condition.
+ generator: Optional generator for adapter-owned stochastic realization.
+
+ Returns:
+ Validated prepared condition reused by every candidate and forward
+ derived from this request.
+ """
+ if not isinstance(condition, Mapping):
+ raise TypeError(
+ "expected condition-state input to be Mapping[str, Any], "
+ f"received {type(condition).__name__}: {condition!r}"
+ )
+ if generator is not None and not isinstance(generator, torch.Generator):
+ raise TypeError(
+ "expected condition-state generator to be torch.Generator or None, "
+ f"received {type(generator).__name__}: {generator!r}"
+ )
+ preparer = self.condition_state_preparer
+ if preparer is None:
+ return PreparedConditionState.identity(condition)
+ with torch.no_grad():
+ prepared = preparer.prepare_condition_state(condition, generator)
+ if not isinstance(prepared, PreparedConditionState):
+ raise TypeError(
+ "condition-state preparer must return PreparedConditionState, "
+ f"received {type(prepared).__name__}"
+ )
+ return prepared
+
# ============================ Output-State Encoding ============================
def _build_output_state_codec_declaration(self) -> Optional[OutputStateCodec]:
"""Build codec metadata without changing component materialization or overrides."""
@@ -562,7 +698,7 @@ def validate_offline_output_capability(cls) -> None:
def _validate_output_state_codec_lifecycle(self) -> Tuple[str, ...]:
"""Validate the adapter's pipeline contract and codec declaration."""
unavailable_reason = type(self)._validated_output_state_codec_unavailable_reason()
- contract = self.pipeline_io_contract
+ contract = self.effective_pipeline_io_contract
if contract is not None and not isinstance(contract, PipelineIOContract):
raise TypeError(
f"adapter {type(self).__name__} expected pipeline_io_contract to be "
@@ -590,14 +726,15 @@ def _validate_output_state_codec_lifecycle(self) -> Tuple[str, ...]:
def encode_output_state(
self,
media_batch: DecodedMediaBatch,
- condition: Mapping[str, Any],
+ condition: Union[Mapping[str, Any], PreparedConditionState],
generator: Optional[torch.Generator] = None,
) -> EncodedOutputState:
"""Encode decoded targets through the adapter-owned validated boundary.
Args:
media_batch: Exact output-media sequence for every batch sample.
- condition: Model-input condition for the same batch.
+ condition: Cached or already-prepared model-input condition for the
+ same batch.
generator: Optional deterministic generator used by stochastic encoders.
Returns:
@@ -614,7 +751,7 @@ def encode_output_state(
f"offline output-state encoding is unavailable for adapter "
f"{type(self).__name__}: {unavailable_reason}"
)
- contract = self.pipeline_io_contract
+ contract = self.effective_pipeline_io_contract
if contract is None:
raise RuntimeError(
f"adapter {type(self).__name__} cannot encode output state because it does not "
@@ -626,11 +763,6 @@ def encode_output_state(
f"adapter {type(self).__name__} declares pipeline_io_contract but does not "
"provide an output-state codec through build_output_state_codec()"
)
- if not isinstance(condition, Mapping):
- raise TypeError(
- "expected output-state condition to be Mapping[str, Any], "
- f"received {type(condition).__name__}: {condition!r}"
- )
if generator is not None and not isinstance(generator, torch.Generator):
raise TypeError(
"expected output-state generator to be torch.Generator or None, "
@@ -638,10 +770,21 @@ def encode_output_state(
)
validated_media = validate_output_candidate_batch(media_batch, contract)
+ if isinstance(condition, PreparedConditionState):
+ prepared_condition = condition
+ elif isinstance(condition, Mapping):
+ prepared_condition = self.prepare_condition_state(condition, generator)
+ else:
+ raise TypeError(
+ "expected output-state condition to be Mapping[str, Any] or "
+ "PreparedConditionState, "
+ f"received {type(condition).__name__}: {condition!r}"
+ )
+ codec_condition = prepared_condition.output_codec_condition()
with torch.no_grad():
encoded = codec.encode_output_state(
validated_media,
- condition,
+ codec_condition,
generator,
)
@@ -672,7 +815,7 @@ def encode_output_state(
device=self.device,
)
- self._validate_encoded_output_geometry(validated_media, condition, encoded)
+ self._validate_encoded_output_geometry(validated_media, codec_condition, encoded)
return encoded
def decode_output_state(
@@ -754,7 +897,8 @@ def _validate_encoded_output_geometry(
raise NotImplementedError(
f"adapter {type(self).__name__} provides an output-state codec but must override "
"_validate_encoded_output_geometry() to validate geometry signatures against "
- f"geometry_source={self.pipeline_io_contract.geometry_source.value!r}"
+ "geometry_source="
+ f"{self.effective_pipeline_io_contract.geometry_source.value!r}"
)
# ============================== Loading Components ==============================
@@ -4371,6 +4515,40 @@ def _reduce_latent_values(
state=state,
)
+ def reduce_flow_matching_objective_values(
+ self,
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: Optional[LatentState] = None,
+ ) -> torch.Tensor:
+ """Reduce offline flow-matching errors to one scalar per sample.
+
+ This objective-specific boundary is deliberately separate from the
+ trajectory-wide element-weighted reducer used by online policy and
+ distillation algorithms. Most adapters inherit the existing global
+ reduction unchanged; multi-modal training recipes may override the
+ protected hook without changing rollout likelihood semantics.
+
+ Args:
+ values: Per-element squared errors in component order.
+ state: Noised state supplying active masks.
+
+ Returns:
+ One flow-matching objective value per batch sample.
+ """
+ batch_size = bridge.validate_reduction_inputs(self, values, state)
+ reduced = self._reduce_flow_matching_objective_values(values, state=state)
+ return bridge.validate_reduced_latent_values(self, reduced, batch_size)
+
+ def _reduce_flow_matching_objective_values(
+ self,
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: Optional[LatentState] = None,
+ ) -> torch.Tensor:
+ """Use the existing globally element-weighted reduction by default."""
+ return self.reduce_latent_values(values, state=state)
+
# ======================================= Sampling & Training =======================================
@abstractmethod
def forward(
diff --git a/src/flow_factory/models/condition_state.py b/src/flow_factory/models/condition_state.py
new file mode 100644
index 000000000..6d386a896
--- /dev/null
+++ b/src/flow_factory/models/condition_state.py
@@ -0,0 +1,203 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Adapter-owned runtime realization of cached model-input conditions."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any, Optional, Protocol, Tuple, runtime_checkable
+
+import torch
+
+from ..contracts import NON_MODEL_CONDITION_KEYS
+
+
+@dataclass(frozen=True, slots=True)
+class PreparedConditionState:
+ """Bundle cached conditions with one realized model-input state.
+
+ Args:
+ condition: Cached input-only fields retained for model forward.
+ forward_context: Runtime input-owned fields added to model forward.
+ output_context: Runtime input-owned fields consumed only while encoding
+ and binding an output target.
+
+ Note:
+ The ownership shell and outer mappings are copied and frozen. Tensor
+ leaves are retained without cloning so online rollout and offline DPO can
+ reuse one exact stochastic condition realization.
+ """
+
+ condition: Mapping[str, Any]
+ forward_context: Mapping[str, Any]
+ output_context: Mapping[str, Any]
+
+ def __post_init__(self) -> None:
+ """Freeze mappings and reject ambiguous field ownership."""
+ condition = _freeze_string_mapping(self.condition, "PreparedConditionState.condition")
+ forward_context = _freeze_string_mapping(
+ self.forward_context,
+ "PreparedConditionState.forward_context",
+ )
+ output_context = _freeze_string_mapping(
+ self.output_context,
+ "PreparedConditionState.output_context",
+ )
+ _reject_non_model_keys(condition, "PreparedConditionState.condition")
+ _reject_non_model_keys(forward_context, "PreparedConditionState.forward_context")
+
+ forward_collisions = tuple(sorted(set(condition).intersection(forward_context)))
+ if forward_collisions:
+ raise ValueError(
+ "prepared condition forward context collides with cached condition keys "
+ f"{forward_collisions}; every model-forward field must have one owner"
+ )
+ output_collisions = tuple(sorted(set(condition).intersection(output_context)))
+ if output_collisions:
+ raise ValueError(
+ "prepared condition output context collides with cached condition keys "
+ f"{output_collisions}; every output-binding field must have one owner"
+ )
+
+ object.__setattr__(self, "condition", condition)
+ object.__setattr__(self, "forward_context", forward_context)
+ object.__setattr__(self, "output_context", output_context)
+
+ @classmethod
+ def identity(cls, condition: Mapping[str, Any]) -> "PreparedConditionState":
+ """Create a realization that preserves the cached condition unchanged.
+
+ Args:
+ condition: Cached input-only model fields.
+
+ Returns:
+ Identity prepared condition with no runtime-owned contexts.
+ """
+ return cls(condition=condition, forward_context={}, output_context={})
+
+ def model_forward_condition(self) -> Mapping[str, Any]:
+ """Return the collision-free model-forward condition mapping."""
+ return MappingProxyType({**self.condition, **self.forward_context})
+
+ def output_codec_condition(self) -> Mapping[str, Any]:
+ """Return cached and output-binding fields for target encoding."""
+ return MappingProxyType({**self.condition, **self.output_context})
+
+
+@runtime_checkable
+class ConditionStatePreparer(Protocol):
+ """Define adapter-owned per-request condition realization."""
+
+ @property
+ def required_components(self) -> Tuple[str, ...]:
+ """Return adapter component names required while realizing conditions."""
+ ...
+
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> PreparedConditionState:
+ """Prepare one exact model-input state from cached conditions."""
+ ...
+
+
+def validate_condition_preparer_required_components(
+ preparer: object,
+ available_components: Sequence[str],
+) -> Tuple[str, ...]:
+ """Validate preparer component requirements against an adapter runtime.
+
+ Args:
+ preparer: Structural condition-state preparer instance.
+ available_components: Canonical component names exposed by the runtime.
+
+ Returns:
+ The preparer's validated required component tuple.
+ """
+ prepare = getattr(preparer, "prepare_condition_state", None)
+ if not callable(prepare):
+ raise TypeError(
+ "expected condition-state preparer with callable prepare_condition_state, "
+ f"received {type(preparer).__name__}"
+ )
+ required_components = getattr(preparer, "required_components", None)
+ _validate_component_names(
+ required_components,
+ "condition preparer.required_components",
+ allow_empty=True,
+ )
+ if isinstance(available_components, (str, bytes)) or not isinstance(
+ available_components, Sequence
+ ):
+ raise TypeError(
+ "expected available_components to be a sequence of strings, "
+ f"received {type(available_components).__name__}: {available_components!r}"
+ )
+ available = tuple(available_components)
+ _validate_component_names(available, "available_components", allow_empty=True)
+ unknown = tuple(name for name in required_components if name not in available)
+ if unknown:
+ raise ValueError(
+ "condition-state preparer requires unknown adapter components "
+ f"{unknown}; available components={available}"
+ )
+ return tuple(required_components)
+
+
+def _freeze_string_mapping(value: object, identifier: str) -> Mapping[str, Any]:
+ if not isinstance(value, Mapping):
+ raise TypeError(
+ f"expected Mapping[str, Any] for {identifier}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+ invalid_keys = tuple(key for key in value if not isinstance(key, str) or not key)
+ if invalid_keys:
+ raise TypeError(f"expected non-empty string keys for {identifier}, got {invalid_keys!r}")
+ return MappingProxyType(dict(value))
+
+
+def _reject_non_model_keys(value: Mapping[str, Any], identifier: str) -> None:
+ rejected = tuple(sorted(set(value).intersection(NON_MODEL_CONDITION_KEYS)))
+ if rejected:
+ raise ValueError(
+ f"{identifier} contains fields that cannot enter model forward: {rejected}"
+ )
+
+
+def _validate_component_names(
+ value: object,
+ identifier: str,
+ *,
+ allow_empty: bool,
+) -> None:
+ if type(value) is not tuple:
+ raise TypeError(f"expected tuple[str, ...] for {identifier}, received {value!r}")
+ if not value and not allow_empty:
+ raise ValueError(f"expected {identifier} to contain at least one component")
+ invalid = tuple(name for name in value if not isinstance(name, str) or not name)
+ if invalid:
+ raise TypeError(f"expected non-empty strings for {identifier}, received {invalid!r}")
+ if len(set(value)) != len(value):
+ raise ValueError(f"expected unique component names for {identifier}, received {value!r}")
+
+
+__all__ = [
+ "ConditionStatePreparer",
+ "PreparedConditionState",
+ "validate_condition_preparer_required_components",
+]
diff --git a/src/flow_factory/models/configured_image_output.py b/src/flow_factory/models/configured_image_output.py
index 39b75e568..a2a5729cb 100644
--- a/src/flow_factory/models/configured_image_output.py
+++ b/src/flow_factory/models/configured_image_output.py
@@ -189,7 +189,7 @@ def build_output_state_codec(self) -> OutputStateCodec:
TypeError: If no pipeline contract is declared.
ValueError: If output media or geometry ownership is incompatible.
"""
- contract = self.pipeline_io_contract
+ contract = self.effective_pipeline_io_contract
if contract is None:
raise TypeError(
f"adapter {type(self).__name__} must declare pipeline_io_contract before "
diff --git a/src/flow_factory/models/ltx2/_output.py b/src/flow_factory/models/ltx2/_output.py
new file mode 100644
index 000000000..c38c9e0a1
--- /dev/null
+++ b/src/flow_factory/models/ltx2/_output.py
@@ -0,0 +1,1066 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""On-the-fly audiovisual target encoding shared by the LTX2 adapters."""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping
+from dataclasses import dataclass
+from numbers import Real
+from types import MappingProxyType
+from typing import Any, ClassVar, Literal, Optional, Tuple
+
+import numpy as np
+import torch
+import torchaudio
+
+from ...contracts import MediaType
+from ...samples import LatentState
+from ...utils.audio import convert_audio
+from ..condition_state import PreparedConditionState
+from ..configured_image_output import retrieve_vae_latents
+from ..output_state import (
+ DecodedMediaBatch,
+ EncodedOutputState,
+ GeometrySignature,
+ MediaGeometrySignature,
+)
+
+LTX2_OFFLINE_FORWARD_OVERRIDES = MappingProxyType(
+ {
+ "guidance_scale": 1.0,
+ "audio_guidance_scale": 1.0,
+ "guidance_rescale": 0.0,
+ "audio_guidance_rescale": 0.0,
+ "stg_scale": 0.0,
+ "audio_stg_scale": 0.0,
+ "spatio_temporal_guidance_blocks": None,
+ "modality_scale": 1.0,
+ "audio_modality_scale": 1.0,
+ "preserve_raw_model_velocity": True,
+ }
+)
+
+
+@dataclass(frozen=True, slots=True)
+class LTX2VideoGeometry:
+ """Canonical configured video and packed-latent geometry."""
+
+ height: int
+ width: int
+ num_frames: int
+ frame_rate: float
+ latent_frames: int
+ latent_height: int
+ latent_width: int
+ latent_channels: int
+ patch_size: int
+ patch_size_t: int
+ sequence_length: int
+ feature_dim: int
+
+
+@dataclass(frozen=True, slots=True)
+class LTX2AudioGeometry:
+ """Canonical configured waveform, mel, and packed-latent geometry."""
+
+ sample_rate: int
+ hop_length: int
+ waveform_channels: int
+ target_samples: int
+ mel_bins: int
+ latent_mel_bins: int
+ latent_channels: int
+ latent_frames: int
+ temporal_compression_ratio: int
+ mel_compression_ratio: int
+ sequence_length: int
+ feature_dim: int
+
+
+@dataclass(frozen=True, slots=True)
+class LTX2OutputGeometry:
+ """One aligned LTX2 video/audio output geometry."""
+
+ video: LTX2VideoGeometry
+ audio: LTX2AudioGeometry
+
+
+@dataclass(frozen=True, slots=True)
+class LTX2FirstFrameConditionPreparer:
+ """Encode an I2AV condition image once for all offline target candidates."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("vae",)
+
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> PreparedConditionState:
+ """Realize the deterministic VideoVAE posterior mode for the first frame."""
+ del generator
+ if "condition_images" not in condition:
+ raise ValueError(
+ "LTX2 I2AV offline condition requires cached `condition_images` pixels"
+ )
+ video_geometry = resolve_ltx2_video_geometry(self.adapter)
+ pixels = condition["condition_images"]
+ if not isinstance(pixels, torch.Tensor):
+ raise TypeError(
+ "LTX2 I2AV condition_images must be a torch.Tensor, "
+ f"received {type(pixels).__name__}"
+ )
+ expected_shape = (
+ pixels.shape[0] if pixels.ndim == 4 else None,
+ 3,
+ video_geometry.height,
+ video_geometry.width,
+ )
+ if pixels.ndim != 4 or tuple(pixels.shape) != expected_shape:
+ raise ValueError(
+ "LTX2 I2AV condition_images must use configured BCHW geometry: "
+ f"expected {expected_shape}, received {tuple(pixels.shape)}"
+ )
+ if not pixels.is_floating_point():
+ raise TypeError(
+ "LTX2 I2AV condition_images must be floating pixels, " f"received {pixels.dtype}"
+ )
+ _require_finite_tensor(pixels, "LTX2 I2AV condition_images")
+
+ vae = self.adapter.get_component("vae")
+ vae_dtype = _floating_module_dtype(vae, "LTX2 VideoVAE")
+ encoded = vae.encode(pixels.to(device=self.adapter.device, dtype=vae_dtype).unsqueeze(2))
+ condition_latents = retrieve_vae_latents(
+ encoded,
+ sample_mode="argmax",
+ source="LTX2 I2AV condition image",
+ ).to(device=self.adapter.device, dtype=torch.float32)
+ expected_latent_shape = (
+ pixels.shape[0],
+ video_geometry.latent_channels,
+ 1,
+ video_geometry.latent_height,
+ video_geometry.latent_width,
+ )
+ if tuple(condition_latents.shape) != expected_latent_shape:
+ raise ValueError(
+ "LTX2 I2AV condition VideoVAE latent geometry mismatch: "
+ f"expected {expected_latent_shape}, received {tuple(condition_latents.shape)}"
+ )
+ _require_finite_tensor(condition_latents, "LTX2 I2AV condition latents")
+
+ # Pixels are an encoder input, not a transformer input. Dropping them from
+ # the realized condition also avoids retaining a full-resolution tensor
+ # through both chosen and rejected offline forwards.
+ model_condition = {
+ key: value for key, value in condition.items() if key != "condition_images"
+ }
+ return PreparedConditionState(
+ condition=model_condition,
+ forward_context={},
+ output_context={"condition_video_latents": condition_latents.detach()},
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class LTX2AVOutputCodec:
+ """Encode exact ``(video, audio)`` targets into LTX2's joint latent state."""
+
+ adapter: Any
+ conditioned: bool = False
+ required_components: ClassVar[Tuple[str, ...]] = ("vae", "audio_vae")
+
+ def encode_output_state(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> EncodedOutputState:
+ """Encode VideoVAE and AudioVAE posterior modes on the configured AV grid."""
+ del generator
+ geometry = resolve_ltx2_output_geometry(self.adapter, conditioned=self.conditioned)
+ videos = []
+ waveforms = []
+ for sample_index, candidate in enumerate(media_batch):
+ if len(candidate) != 2:
+ raise ValueError(
+ "LTX2 output codec expected exact (video, audio) media, "
+ f"received {len(candidate)} items for sample {sample_index}"
+ )
+ video_media, audio_media = candidate
+ videos.append(
+ prepare_ltx2_target_video(
+ video_media.payload,
+ source_fps=video_media.fps,
+ geometry=geometry.video,
+ )
+ )
+ waveforms.append(
+ prepare_ltx2_target_audio(
+ audio_media.payload,
+ source_sample_rate=audio_media.sample_rate,
+ geometry=geometry.audio,
+ duration_seconds=geometry.video.num_frames / geometry.video.frame_rate,
+ )
+ )
+
+ video_latents = encode_ltx2_target_video(self.adapter, videos, geometry.video)
+ if self.conditioned:
+ condition_latents = condition.get("condition_video_latents")
+ expected_condition_shape = (
+ len(media_batch),
+ geometry.video.latent_channels,
+ 1,
+ geometry.video.latent_height,
+ geometry.video.latent_width,
+ )
+ if not isinstance(condition_latents, torch.Tensor):
+ raise TypeError(
+ "LTX2 I2AV output binding requires condition_video_latents from "
+ "prepare_condition_state()"
+ )
+ if tuple(condition_latents.shape) != expected_condition_shape:
+ raise ValueError(
+ "LTX2 I2AV condition latent batch/geometry mismatch: "
+ f"expected {expected_condition_shape}, received "
+ f"{tuple(condition_latents.shape)}"
+ )
+ condition_latents = condition_latents.to(
+ device=self.adapter.device,
+ dtype=torch.float32,
+ )
+ _require_finite_tensor(condition_latents, "LTX2 I2AV condition latents")
+ video_latents = torch.cat(
+ [condition_latents, video_latents[:, :, 1:]],
+ dim=2,
+ )
+
+ packed_video = normalize_and_pack_ltx2_video(
+ self.adapter,
+ video_latents,
+ geometry.video,
+ )
+ packed_audio = encode_ltx2_target_audio(
+ self.adapter,
+ torch.stack(waveforms),
+ geometry.audio,
+ )
+
+ forward_context = _ltx2_forward_context(geometry)
+ active_masks = None
+ if self.conditioned:
+ unpacked_mask = packed_video.new_zeros(
+ (
+ len(media_batch),
+ 1,
+ geometry.video.latent_frames,
+ geometry.video.latent_height,
+ geometry.video.latent_width,
+ )
+ )
+ unpacked_mask[:, :, 0] = 1.0
+ conditioning_mask = self.adapter.pipeline._pack_latents(
+ unpacked_mask,
+ geometry.video.patch_size,
+ geometry.video.patch_size_t,
+ )
+ if conditioning_mask.shape[-1] != 1:
+ raise ValueError(
+ "LTX2 I2AV conditioning mask requires one scalar per packed token, "
+ f"received packed shape {tuple(conditioning_mask.shape)}"
+ )
+ conditioning_mask = conditioning_mask.squeeze(-1)
+ forward_context["conditioning_mask"] = conditioning_mask
+ active_masks = {
+ "video": (~conditioning_mask.bool()).unsqueeze(-1),
+ "audio": torch.ones(
+ (len(media_batch), geometry.audio.sequence_length, 1),
+ device=packed_audio.device,
+ dtype=torch.bool,
+ ),
+ }
+
+ signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=geometry.video.height,
+ width=geometry.video.width,
+ frames=geometry.video.num_frames,
+ fps=geometry.video.frame_rate,
+ ),
+ MediaGeometrySignature(
+ type=MediaType.AUDIO,
+ samples=geometry.audio.target_samples,
+ sample_rate=geometry.audio.sample_rate,
+ ),
+ )
+ )
+ return EncodedOutputState(
+ clean_state=LatentState(
+ {"video": packed_video.detach(), "audio": packed_audio.detach()},
+ active_masks=active_masks,
+ ),
+ forward_context=forward_context,
+ decode_context={
+ "height": geometry.video.height,
+ "width": geometry.video.width,
+ "num_frames": geometry.video.num_frames,
+ "frame_rate": geometry.video.frame_rate,
+ },
+ geometry_signatures=tuple(signature for _ in media_batch),
+ )
+
+
+def resolve_ltx2_video_geometry(adapter: Any) -> LTX2VideoGeometry:
+ """Resolve configured video geometry from runtime VAE and transformer metadata."""
+ height = _positive_int(getattr(adapter.training_args, "height", None), "training_args.height")
+ width = _positive_int(getattr(adapter.training_args, "width", None), "training_args.width")
+ num_frames = _positive_int(
+ getattr(adapter.training_args, "num_frames", None),
+ "training_args.num_frames",
+ )
+ frame_rate = _positive_real(
+ getattr(adapter.training_args, "frame_rate", None),
+ "training_args.frame_rate",
+ )
+ pipeline = adapter.pipeline
+ spatial_ratio = _positive_int(
+ getattr(pipeline, "vae_spatial_compression_ratio", None),
+ "pipeline.vae_spatial_compression_ratio",
+ )
+ temporal_ratio = _positive_int(
+ getattr(pipeline, "vae_temporal_compression_ratio", None),
+ "pipeline.vae_temporal_compression_ratio",
+ )
+ if height % spatial_ratio or width % spatial_ratio:
+ raise ValueError(
+ "LTX2 configured height/width must be divisible by the VideoVAE spatial "
+ f"compression ratio {spatial_ratio}, received {(height, width)}"
+ )
+ if (num_frames - 1) % temporal_ratio:
+ raise ValueError(
+ "LTX2 configured num_frames must satisfy "
+ f"(num_frames - 1) % {temporal_ratio} == 0, received {num_frames}"
+ )
+ latent_frames = (num_frames - 1) // temporal_ratio + 1
+ latent_height = height // spatial_ratio
+ latent_width = width // spatial_ratio
+ patch_size = _positive_int(
+ getattr(pipeline, "transformer_spatial_patch_size", None),
+ "pipeline.transformer_spatial_patch_size",
+ )
+ patch_size_t = _positive_int(
+ getattr(pipeline, "transformer_temporal_patch_size", None),
+ "pipeline.transformer_temporal_patch_size",
+ )
+ if latent_frames % patch_size_t or latent_height % patch_size or latent_width % patch_size:
+ raise ValueError(
+ "LTX2 latent video grid must be divisible by transformer patches "
+ f"(time={patch_size_t}, spatial={patch_size}), received "
+ f"{(latent_frames, latent_height, latent_width)}"
+ )
+ vae = adapter.get_component("vae")
+ latent_channels = _positive_int(
+ getattr(getattr(vae, "config", None), "latent_channels", None),
+ "vae.config.latent_channels",
+ )
+ feature_dim = latent_channels * patch_size_t * patch_size * patch_size
+ sequence_length = (
+ latent_frames // patch_size_t * (latent_height // patch_size) * (latent_width // patch_size)
+ )
+ return LTX2VideoGeometry(
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ frame_rate=frame_rate,
+ latent_frames=latent_frames,
+ latent_height=latent_height,
+ latent_width=latent_width,
+ latent_channels=latent_channels,
+ patch_size=patch_size,
+ patch_size_t=patch_size_t,
+ sequence_length=sequence_length,
+ feature_dim=feature_dim,
+ )
+
+
+def resolve_ltx2_output_geometry(
+ adapter: Any,
+ *,
+ conditioned: bool,
+) -> LTX2OutputGeometry:
+ """Resolve and cross-check the complete config-driven LTX2 AV geometry."""
+ video = resolve_ltx2_video_geometry(adapter)
+ if conditioned and (video.patch_size != 1 or video.patch_size_t != 1):
+ raise ValueError(
+ "LTX2 I2AV's official scalar conditioning mask requires video patch_size=1 "
+ f"and patch_size_t=1, received {(video.patch_size, video.patch_size_t)}"
+ )
+
+ pipeline = adapter.pipeline
+ audio_vae = adapter.get_component("audio_vae")
+ audio_config = getattr(audio_vae, "config", None)
+ sample_rate = _positive_int(
+ getattr(audio_config, "sample_rate", None),
+ "audio_vae.config.sample_rate",
+ )
+ hop_length = _positive_int(
+ getattr(audio_config, "mel_hop_length", None),
+ "audio_vae.config.mel_hop_length",
+ )
+ mel_bins = _positive_int(
+ getattr(audio_config, "mel_bins", None),
+ "audio_vae.config.mel_bins",
+ )
+ waveform_channels = _positive_int(
+ getattr(audio_config, "in_channels", None),
+ "audio_vae.config.in_channels",
+ )
+ if waveform_channels not in (1, 2):
+ raise ValueError(
+ "LTX2 audio frontend supports mono or stereo AudioVAE inputs, "
+ f"received in_channels={waveform_channels}"
+ )
+ latent_channels = _positive_int(
+ getattr(audio_config, "latent_channels", None),
+ "audio_vae.config.latent_channels",
+ )
+ temporal_ratio = _positive_int(
+ getattr(pipeline, "audio_vae_temporal_compression_ratio", None),
+ "pipeline.audio_vae_temporal_compression_ratio",
+ )
+ mel_ratio = _positive_int(
+ getattr(pipeline, "audio_vae_mel_compression_ratio", None),
+ "pipeline.audio_vae_mel_compression_ratio",
+ )
+ if mel_bins % mel_ratio:
+ raise ValueError(
+ "LTX2 AudioVAE mel bins must be divisible by mel compression ratio, "
+ f"received mel_bins={mel_bins}, ratio={mel_ratio}"
+ )
+ pipeline_sample_rate = _positive_int(
+ getattr(pipeline, "audio_sampling_rate", None),
+ "pipeline.audio_sampling_rate",
+ )
+ pipeline_hop_length = _positive_int(
+ getattr(pipeline, "audio_hop_length", None),
+ "pipeline.audio_hop_length",
+ )
+ if (sample_rate, hop_length) != (pipeline_sample_rate, pipeline_hop_length):
+ raise ValueError(
+ "LTX2 pipeline and AudioVAE audio clocks disagree: "
+ f"pipeline={(pipeline_sample_rate, pipeline_hop_length)}, "
+ f"audio_vae={(sample_rate, hop_length)}"
+ )
+
+ duration_seconds = video.num_frames / video.frame_rate
+ target_samples = max(round(duration_seconds * sample_rate), 1)
+ latent_frames = round(duration_seconds * sample_rate / hop_length / temporal_ratio)
+ if latent_frames < 1:
+ raise ValueError(
+ "LTX2 configured AV duration resolves to no audio latent frames: "
+ f"duration={duration_seconds}, sample_rate={sample_rate}, "
+ f"hop_length={hop_length}, temporal_ratio={temporal_ratio}"
+ )
+ latent_mel_bins = mel_bins // mel_ratio
+ feature_dim = latent_channels * latent_mel_bins
+ transformer_config = _component_config(adapter, "transformer")
+ transformer_video_dim = _positive_int(
+ getattr(transformer_config, "in_channels", None),
+ "transformer.config.in_channels",
+ )
+ transformer_audio_dim = _positive_int(
+ getattr(transformer_config, "audio_in_channels", None),
+ "transformer.config.audio_in_channels",
+ )
+ if video.feature_dim != transformer_video_dim:
+ raise ValueError(
+ "LTX2 packed video feature width disagrees with transformer config: "
+ f"expected {transformer_video_dim}, resolved {video.feature_dim}"
+ )
+ if feature_dim != transformer_audio_dim:
+ raise ValueError(
+ "LTX2 packed audio feature width disagrees with transformer config: "
+ f"expected {transformer_audio_dim}, resolved {feature_dim}"
+ )
+ audio_patch_size = _positive_int(
+ getattr(transformer_config, "audio_patch_size", 1),
+ "transformer.config.audio_patch_size",
+ )
+ audio_patch_size_t = _positive_int(
+ getattr(transformer_config, "audio_patch_size_t", 1),
+ "transformer.config.audio_patch_size_t",
+ )
+ if (audio_patch_size, audio_patch_size_t) != (1, 1):
+ raise ValueError(
+ "LTX2 Diffusers 0.40 packs audio as one full-mel token per latent time; "
+ "non-unit audio patching is not supported by the online pipeline, received "
+ f"{(audio_patch_size, audio_patch_size_t)}"
+ )
+
+ audio = LTX2AudioGeometry(
+ sample_rate=sample_rate,
+ hop_length=hop_length,
+ waveform_channels=waveform_channels,
+ target_samples=target_samples,
+ mel_bins=mel_bins,
+ latent_mel_bins=latent_mel_bins,
+ latent_channels=latent_channels,
+ latent_frames=latent_frames,
+ temporal_compression_ratio=temporal_ratio,
+ mel_compression_ratio=mel_ratio,
+ sequence_length=latent_frames,
+ feature_dim=feature_dim,
+ )
+ return LTX2OutputGeometry(video=video, audio=audio)
+
+
+def prepare_ltx2_target_video(
+ payload: Any,
+ *,
+ source_fps: Any,
+ geometry: LTX2VideoGeometry,
+) -> np.ndarray:
+ """Select deterministic nearest-time RGB frames on the configured cadence."""
+ if not isinstance(payload, np.ndarray):
+ raise TypeError(
+ "LTX2 target video expected a decoded NumPy array, "
+ f"received {type(payload).__name__}"
+ )
+ if payload.dtype != np.uint8 or payload.ndim != 4 or payload.shape[-1] != 3:
+ raise ValueError(
+ "LTX2 target video must be uint8 RGB shaped (F,H,W,3), "
+ f"received dtype={payload.dtype}, shape={tuple(payload.shape)}"
+ )
+ if payload.shape[0] < 1:
+ raise ValueError("LTX2 target video must contain at least one frame")
+ source_fps = _positive_real(source_fps, "target video fps")
+ indices = np.rint(
+ np.arange(geometry.num_frames, dtype=np.float64) * source_fps / geometry.frame_rate
+ ).astype(np.int64)
+ if indices[-1] >= payload.shape[0]:
+ required_duration = (geometry.num_frames - 1) / geometry.frame_rate
+ available_duration = (payload.shape[0] - 1) / source_fps
+ raise ValueError(
+ "LTX2 target video is too short for configured temporal geometry: "
+ f"requires {required_duration:.6f}s, has {available_duration:.6f}s"
+ )
+ return np.ascontiguousarray(payload[indices])
+
+
+def prepare_ltx2_target_audio(
+ payload: Any,
+ *,
+ source_sample_rate: Any,
+ geometry: LTX2AudioGeometry,
+ duration_seconds: float,
+) -> torch.Tensor:
+ """Convert one waveform to the exact official LTX2 model-rate audio clock."""
+ if not isinstance(payload, torch.Tensor):
+ raise TypeError(
+ "LTX2 target audio expected a decoded torch.Tensor, "
+ f"received {type(payload).__name__}"
+ )
+ if payload.ndim != 2 or payload.shape[0] not in (1, 2) or payload.shape[1] < 1:
+ raise ValueError(
+ "LTX2 target audio must be non-empty mono/stereo shaped (C,S), "
+ f"received {tuple(payload.shape)}"
+ )
+ if not payload.is_floating_point():
+ raise TypeError(f"LTX2 target audio expected floating waveform, received {payload.dtype}")
+ _require_finite_tensor(payload, "LTX2 target audio")
+ source_sample_rate = _positive_int(source_sample_rate, "target audio sample_rate")
+ duration_seconds = _positive_real(duration_seconds, "target AV duration")
+ source_samples = int(duration_seconds * source_sample_rate)
+ if source_samples < 1:
+ raise ValueError("LTX2 target AV duration resolves to fewer than one source audio sample")
+ source_waveform = payload.detach().to(device="cpu", dtype=torch.float32)[:, :source_samples]
+ waveform = convert_audio(
+ source_waveform,
+ from_rate=source_sample_rate,
+ to_rate=geometry.sample_rate,
+ to_channels=geometry.waveform_channels,
+ )
+ if waveform.shape[-1] >= geometry.target_samples:
+ waveform = waveform[:, : geometry.target_samples]
+ else:
+ waveform = torch.nn.functional.pad(
+ waveform,
+ (0, geometry.target_samples - waveform.shape[-1]),
+ )
+ return waveform.contiguous()
+
+
+def ltx2_log_mel_spectrogram(
+ waveforms: torch.Tensor,
+ *,
+ sample_rate: int,
+ hop_length: int,
+ mel_bins: int,
+) -> torch.Tensor:
+ """Apply Lightricks' official magnitude Slaney log-mel frontend.
+
+ The frontend is intentionally independent from the vocoder's inverse-STFT
+ helpers: those use a different FFT and hop for bandwidth extension and are
+ not the AudioVAE training representation.
+ """
+ if not isinstance(waveforms, torch.Tensor) or waveforms.ndim != 3:
+ raise ValueError(
+ "LTX2 log-mel frontend expected waveform tensor shaped (B,C,S), "
+ f"received {type(waveforms).__name__}/{getattr(waveforms, 'shape', None)}"
+ )
+ if not waveforms.is_floating_point():
+ raise TypeError(
+ f"LTX2 log-mel frontend expected floating waveform, received {waveforms.dtype}"
+ )
+ _require_finite_tensor(waveforms, "LTX2 log-mel waveform")
+ sample_rate = _positive_int(sample_rate, "LTX2 log-mel sample_rate")
+ hop_length = _positive_int(hop_length, "LTX2 log-mel hop_length")
+ mel_bins = _positive_int(mel_bins, "LTX2 log-mel mel_bins")
+ if waveforms.shape[-1] <= 512:
+ raise ValueError(
+ "LTX2 official centered 1024-point log-mel frontend requires more than "
+ f"512 waveform samples for reflect padding, received {waveforms.shape[-1]}"
+ )
+ mel_spectrogram = getattr(getattr(torchaudio, "transforms", None), "MelSpectrogram", None)
+ if not callable(mel_spectrogram):
+ raise RuntimeError(
+ "LTX2 target audio encoding requires torchaudio.transforms.MelSpectrogram"
+ )
+ frontend = mel_spectrogram(
+ sample_rate=sample_rate,
+ n_fft=1024,
+ win_length=1024,
+ hop_length=hop_length,
+ f_min=0.0,
+ f_max=sample_rate / 2,
+ n_mels=mel_bins,
+ window_fn=torch.hann_window,
+ center=True,
+ pad_mode="reflect",
+ power=1.0,
+ norm="slaney",
+ mel_scale="slaney",
+ ).to(device=waveforms.device, dtype=torch.float32)
+ magnitude_mel = frontend(waveforms.to(torch.float32))
+ log_mel = magnitude_mel.clamp_min(1e-5).log().permute(0, 1, 3, 2).contiguous()
+ _require_finite_tensor(log_mel, "LTX2 log-mel spectrogram")
+ return log_mel
+
+
+def encode_ltx2_target_video(
+ adapter: Any,
+ videos: list[np.ndarray],
+ geometry: LTX2VideoGeometry,
+) -> torch.Tensor:
+ """Preprocess videos and take the deterministic VideoVAE posterior mode."""
+ pixels = adapter.pipeline.video_processor.preprocess_video(
+ videos,
+ height=geometry.height,
+ width=geometry.width,
+ )
+ if not isinstance(pixels, torch.Tensor):
+ raise TypeError(
+ "LTX2 video_processor.preprocess_video must return torch.Tensor, "
+ f"received {type(pixels).__name__}"
+ )
+ expected_shape = (len(videos), 3, geometry.num_frames, geometry.height, geometry.width)
+ if tuple(pixels.shape) != expected_shape:
+ raise ValueError(
+ "LTX2 target video preprocessing changed configured geometry: "
+ f"expected {expected_shape}, received {tuple(pixels.shape)}"
+ )
+ if not pixels.is_floating_point():
+ raise TypeError(
+ f"LTX2 target video preprocessing must return floating pixels, got {pixels.dtype}"
+ )
+ _require_finite_tensor(pixels, "LTX2 target video pixels")
+ vae = adapter.get_component("vae")
+ encoded = vae.encode(
+ pixels.to(
+ device=adapter.device,
+ dtype=_floating_module_dtype(vae, "LTX2 VideoVAE"),
+ )
+ )
+ latents = retrieve_vae_latents(
+ encoded,
+ sample_mode="argmax",
+ source="LTX2 target video",
+ ).to(device=adapter.device, dtype=torch.float32)
+ expected_latent_shape = (
+ len(videos),
+ geometry.latent_channels,
+ geometry.latent_frames,
+ geometry.latent_height,
+ geometry.latent_width,
+ )
+ if tuple(latents.shape) != expected_latent_shape:
+ raise ValueError(
+ "LTX2 target VideoVAE latent geometry mismatch: "
+ f"expected {expected_latent_shape}, received {tuple(latents.shape)}"
+ )
+ _require_finite_tensor(latents, "LTX2 target video latents")
+ return latents
+
+
+def normalize_and_pack_ltx2_video(
+ adapter: Any,
+ latents: torch.Tensor,
+ geometry: LTX2VideoGeometry,
+) -> torch.Tensor:
+ """Apply Diffusers' LTX2 video normalization and patch packing."""
+ vae = adapter.get_component("vae")
+ scaling_factor = _positive_real(
+ getattr(getattr(vae, "config", None), "scaling_factor", None),
+ "vae.config.scaling_factor",
+ )
+ normalized = adapter.pipeline._normalize_latents(
+ latents.to(torch.float32),
+ vae.latents_mean,
+ vae.latents_std,
+ scaling_factor,
+ )
+ packed = adapter.pipeline._pack_latents(
+ normalized,
+ geometry.patch_size,
+ geometry.patch_size_t,
+ ).to(device=adapter.device, dtype=torch.float32)
+ expected_shape = (
+ latents.shape[0],
+ geometry.sequence_length,
+ geometry.feature_dim,
+ )
+ if tuple(packed.shape) != expected_shape:
+ raise ValueError(
+ "LTX2 packed target video geometry mismatch: "
+ f"expected {expected_shape}, received {tuple(packed.shape)}"
+ )
+ _require_finite_tensor(packed, "LTX2 packed target video")
+ return packed
+
+
+def encode_ltx2_target_audio(
+ adapter: Any,
+ waveforms: torch.Tensor,
+ geometry: LTX2AudioGeometry,
+) -> torch.Tensor:
+ """Apply the official frontend, AudioVAE mode, conformance, packing, and normalization."""
+ expected_waveform_shape = (
+ waveforms.shape[0] if waveforms.ndim == 3 else None,
+ geometry.waveform_channels,
+ geometry.target_samples,
+ )
+ if waveforms.ndim != 3 or tuple(waveforms.shape) != expected_waveform_shape:
+ raise ValueError(
+ "LTX2 target waveform batch geometry mismatch: "
+ f"expected {expected_waveform_shape}, received {tuple(waveforms.shape)}"
+ )
+ log_mel = ltx2_log_mel_spectrogram(
+ waveforms.to(device="cpu", dtype=torch.float32),
+ sample_rate=geometry.sample_rate,
+ hop_length=geometry.hop_length,
+ mel_bins=geometry.mel_bins,
+ )
+ audio_vae = adapter.get_component("audio_vae")
+ encoded = audio_vae.encode(
+ log_mel.to(
+ device=adapter.device,
+ dtype=_floating_module_dtype(audio_vae, "LTX2 AudioVAE"),
+ )
+ )
+ latents = retrieve_vae_latents(
+ encoded,
+ sample_mode="argmax",
+ source="LTX2 target audio",
+ ).to(device=adapter.device, dtype=torch.float32)
+ if (
+ latents.ndim != 4
+ or latents.shape[0] != waveforms.shape[0]
+ or latents.shape[1] != geometry.latent_channels
+ or latents.shape[3] != geometry.latent_mel_bins
+ ):
+ raise ValueError(
+ "LTX2 target AudioVAE latent geometry mismatch: expected "
+ f"(B={waveforms.shape[0]}, C={geometry.latent_channels}, T, "
+ f"M={geometry.latent_mel_bins}), received {tuple(latents.shape)}"
+ )
+ if latents.shape[2] >= geometry.latent_frames:
+ latents = latents[:, :, : geometry.latent_frames]
+ else:
+ latents = torch.nn.functional.pad(
+ latents,
+ (0, 0, 0, geometry.latent_frames - latents.shape[2]),
+ )
+ packed = adapter.pipeline._pack_audio_latents(latents)
+ normalized = adapter.pipeline._normalize_audio_latents(
+ packed,
+ audio_vae.latents_mean,
+ audio_vae.latents_std,
+ ).to(device=adapter.device, dtype=torch.float32)
+ expected_shape = (
+ waveforms.shape[0],
+ geometry.sequence_length,
+ geometry.feature_dim,
+ )
+ if tuple(normalized.shape) != expected_shape:
+ raise ValueError(
+ "LTX2 packed target audio geometry mismatch: "
+ f"expected {expected_shape}, received {tuple(normalized.shape)}"
+ )
+ _require_finite_tensor(normalized, "LTX2 packed target audio")
+ return normalized
+
+
+def validate_ltx2_encoded_output_geometry(
+ adapter: Any,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ *,
+ conditioned: bool,
+) -> None:
+ """Validate self-reported codec geometry against runtime configs and I2AV binding."""
+ geometry = resolve_ltx2_output_geometry(adapter, conditioned=conditioned)
+ batch_size = len(media_batch)
+ expected_video_shape = (
+ batch_size,
+ geometry.video.sequence_length,
+ geometry.video.feature_dim,
+ )
+ expected_audio_shape = (
+ batch_size,
+ geometry.audio.sequence_length,
+ geometry.audio.feature_dim,
+ )
+ video = encoded.clean_state.components["video"]
+ audio = encoded.clean_state.components["audio"]
+ if tuple(video.shape) != expected_video_shape or tuple(audio.shape) != expected_audio_shape:
+ raise ValueError(
+ "LTX2 encoded component geometry mismatch: expected "
+ f"video={expected_video_shape}, audio={expected_audio_shape}; received "
+ f"video={tuple(video.shape)}, audio={tuple(audio.shape)}"
+ )
+
+ expected_forward = _ltx2_forward_context(geometry)
+ for key, value in expected_forward.items():
+ if encoded.forward_context.get(key) != value:
+ raise ValueError(
+ f"LTX2 encoded forward_context[{key!r}] mismatch: "
+ f"expected {value!r}, received {encoded.forward_context.get(key)!r}"
+ )
+ expected_decode = {
+ "height": geometry.video.height,
+ "width": geometry.video.width,
+ "num_frames": geometry.video.num_frames,
+ "frame_rate": geometry.video.frame_rate,
+ }
+ for key, value in expected_decode.items():
+ if encoded.decode_context.get(key) != value:
+ raise ValueError(
+ f"LTX2 encoded decode_context[{key!r}] mismatch: "
+ f"expected {value!r}, received {encoded.decode_context.get(key)!r}"
+ )
+
+ expected_signature = GeometrySignature(
+ media=(
+ MediaGeometrySignature(
+ type=MediaType.VIDEO,
+ height=geometry.video.height,
+ width=geometry.video.width,
+ frames=geometry.video.num_frames,
+ fps=geometry.video.frame_rate,
+ ),
+ MediaGeometrySignature(
+ type=MediaType.AUDIO,
+ samples=geometry.audio.target_samples,
+ sample_rate=geometry.audio.sample_rate,
+ ),
+ )
+ )
+ if encoded.geometry_signatures != tuple(expected_signature for _ in media_batch):
+ raise ValueError("LTX2 encoded geometry signatures do not match the configured AV geometry")
+
+ if not conditioned:
+ if encoded.clean_state.active_masks is not None:
+ raise ValueError("LTX2 T2AV clean targets must not carry active masks")
+ return
+
+ condition_latents = condition.get("condition_video_latents")
+ expected_condition_shape = (
+ batch_size,
+ geometry.video.latent_channels,
+ 1,
+ geometry.video.latent_height,
+ geometry.video.latent_width,
+ )
+ if (
+ not isinstance(condition_latents, torch.Tensor)
+ or tuple(condition_latents.shape) != expected_condition_shape
+ ):
+ raise ValueError(
+ "LTX2 I2AV encoded output is not bound to the prepared first-frame condition: "
+ f"expected {expected_condition_shape}, received "
+ f"{getattr(condition_latents, 'shape', None)}"
+ )
+ conditioning_mask = encoded.forward_context.get("conditioning_mask")
+ if not isinstance(conditioning_mask, torch.Tensor) or tuple(conditioning_mask.shape) != (
+ batch_size,
+ geometry.video.sequence_length,
+ ):
+ raise ValueError(
+ "LTX2 I2AV conditioning_mask geometry mismatch: expected "
+ f"{(batch_size, geometry.video.sequence_length)}, received "
+ f"{getattr(conditioning_mask, 'shape', None)}"
+ )
+ first_frame_tokens = (
+ geometry.video.latent_height
+ // geometry.video.patch_size
+ * (geometry.video.latent_width // geometry.video.patch_size)
+ )
+ expected_conditioning_mask = torch.zeros_like(conditioning_mask, dtype=torch.bool)
+ expected_conditioning_mask[:, :first_frame_tokens] = True
+ if not torch.equal(conditioning_mask.bool(), expected_conditioning_mask):
+ raise ValueError(
+ "LTX2 I2AV conditioning_mask must pin exactly the first latent video frame"
+ )
+ active_masks = encoded.clean_state.active_masks
+ if active_masks is None:
+ raise ValueError("LTX2 I2AV clean targets require video/audio active masks")
+ if not torch.equal(
+ active_masks["video"].reshape_as(conditioning_mask),
+ ~expected_conditioning_mask,
+ ):
+ raise ValueError("LTX2 I2AV video active mask must be the inverse conditioning mask")
+ if not bool(active_masks["audio"].all()):
+ raise ValueError("LTX2 I2AV audio target must remain fully active")
+
+
+def reduce_ltx2_flow_matching_objective_values(
+ adapter: Any,
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: Optional[LatentState],
+) -> torch.Tensor:
+ """Sum official per-modality means only for the offline flow objective.
+
+ LTX2 trains the video and audio denoisers as two equally weighted terms.
+ Keeping this policy behind the flow-matching-specific adapter hook preserves
+ the element-weighted joint likelihood used by online RL and distillation.
+ """
+ component_means = adapter.reduce_component_latent_values(values, state=state)
+ return component_means["video"] + component_means["audio"]
+
+
+def decode_ltx2_output_state(
+ adapter: Any,
+ encoded: EncodedOutputState,
+ *,
+ output_type: Literal["pil", "pt", "np"],
+) -> Any:
+ """Route the shared video/audio state through the existing LTX2 decoder."""
+ if encoded.clean_state.component_names != ("video", "audio"):
+ raise ValueError(
+ "LTX2 output decoding requires component order ('video', 'audio'), "
+ f"received {encoded.clean_state.component_names}"
+ )
+ context = encoded.decode_context
+ return adapter.decode_latents(
+ encoded.clean_state.components["video"],
+ encoded.clean_state.components["audio"],
+ height=context["height"],
+ width=context["width"],
+ num_frames=context["num_frames"],
+ frame_rate=context["frame_rate"],
+ output_type=output_type,
+ )
+
+
+def _ltx2_forward_context(geometry: LTX2OutputGeometry) -> dict[str, Any]:
+ return {
+ "height": geometry.video.height,
+ "width": geometry.video.width,
+ "num_frames": geometry.video.num_frames,
+ "frame_rate": geometry.video.frame_rate,
+ "video_seq_len": geometry.video.sequence_length,
+ "audio_num_frames": geometry.audio.latent_frames,
+ }
+
+
+def _component_config(adapter: Any, name: str) -> Any:
+ getter = getattr(adapter, "get_component_config", None)
+ if callable(getter):
+ return getter(name)
+ component = adapter.get_component(name)
+ config = getattr(component, "config", None)
+ if config is None:
+ raise TypeError(f"LTX2 component {name!r} does not expose config")
+ return config
+
+
+def _floating_module_dtype(module: Any, identifier: str) -> torch.dtype:
+ dtype = getattr(module, "dtype", None)
+ if isinstance(dtype, torch.dtype) and dtype.is_floating_point:
+ return dtype
+ parameters = getattr(module, "parameters", None)
+ if callable(parameters):
+ first = next(iter(parameters()), None)
+ if isinstance(first, torch.Tensor) and first.dtype.is_floating_point:
+ return first.dtype
+ raise TypeError(f"{identifier} must expose a floating dtype, received {dtype!r}")
+
+
+def _positive_int(value: Any, identifier: str) -> int:
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise TypeError(
+ f"expected positive int for {identifier}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+ if value <= 0:
+ raise ValueError(f"expected positive int for {identifier}, received {value}")
+ return value
+
+
+def _positive_real(value: Any, identifier: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, Real):
+ raise TypeError(
+ f"expected positive real for {identifier}, received "
+ f"{type(value).__name__}: {value!r}"
+ )
+ value = float(value)
+ if not math.isfinite(value) or value <= 0:
+ raise ValueError(f"expected positive finite real for {identifier}, received {value!r}")
+ return value
+
+
+def _require_finite_tensor(value: torch.Tensor, identifier: str) -> None:
+ if not bool(torch.isfinite(value).all()):
+ raise ValueError(f"{identifier} contains non-finite values")
+
+
+__all__ = [
+ "LTX2AVOutputCodec",
+ "LTX2FirstFrameConditionPreparer",
+ "LTX2OutputGeometry",
+ "LTX2_OFFLINE_FORWARD_OVERRIDES",
+ "decode_ltx2_output_state",
+ "ltx2_log_mel_spectrogram",
+ "reduce_ltx2_flow_matching_objective_values",
+ "resolve_ltx2_output_geometry",
+ "resolve_ltx2_video_geometry",
+ "validate_ltx2_encoded_output_geometry",
+]
diff --git a/src/flow_factory/models/ltx2/ltx2_i2av.py b/src/flow_factory/models/ltx2/ltx2_i2av.py
index 9fa9261df..fdcba6d65 100644
--- a/src/flow_factory/models/ltx2/ltx2_i2av.py
+++ b/src/flow_factory/models/ltx2/ltx2_i2av.py
@@ -26,6 +26,12 @@
)
from PIL import Image
+from ...contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaRule,
+ NegativePromptPolicy,
+)
from ...hparams import *
from ...samples import (
ComponentTimes,
@@ -58,6 +64,8 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..output_state import DecodedMediaBatch, EncodedOutputState
+from ..pipeline_contracts import IMAGE_FORMAT, audio_video_output_contract
from ._common import (
LTX2_COMPONENT_ORDER,
attach_ltx2_state_masks,
@@ -73,6 +81,14 @@
split_ltx2_callback_results,
validate_i2av_forward_state_inputs,
)
+from ._output import (
+ LTX2_OFFLINE_FORWARD_OVERRIDES,
+ LTX2AVOutputCodec,
+ LTX2FirstFrameConditionPreparer,
+ decode_ltx2_output_state,
+ reduce_ltx2_flow_matching_objective_values,
+ validate_ltx2_encoded_output_geometry,
+)
logger = setup_logger(__name__)
@@ -177,11 +193,22 @@ class LTX2_I2AV_Adapter(BaseAdapter):
Shared logic with LTX2_T2AV_Adapter is handled via code duplication.
"""
- output_state_codec_unavailable_reason = (
- "LTX2 I2AV offline targets require paired video/audio decoding, exact duration "
- "alignment, and an active mask for the pinned first-frame condition; those lossless "
- "audiovisual output semantics are not yet implemented"
+ component_load_dtype_defaults = {"audio_vae": torch.float32}
+ pipeline_io_contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ input_rules=(
+ InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=1,
+ max_count=1,
+ slots=("first_frame",),
+ required_slots=("first_frame",),
+ ),
+ ),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
)
+ offline_training_forward_overrides = LTX2_OFFLINE_FORWARD_OVERRIDES
supports_diffusers_cache = True
trajectory_component_order: ClassVar[Tuple[str, ...]] = LTX2_COMPONENT_ORDER
@@ -270,6 +297,51 @@ def inference_modules(self) -> List[str]:
"""Components needed during inference and training forward."""
return ["transformer", "vae", "audio_vae", "connectors", "vocoder"]
+ def build_condition_state_preparer(self) -> LTX2FirstFrameConditionPreparer:
+ """Declare one-time deterministic first-frame condition encoding."""
+ return LTX2FirstFrameConditionPreparer(self)
+
+ def build_output_state_codec(self) -> LTX2AVOutputCodec:
+ """Declare deterministic on-the-fly encoding for paired AV targets."""
+ return LTX2AVOutputCodec(self, conditioned=True)
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Validate AV geometry and the pinned first-frame binding."""
+ validate_ltx2_encoded_output_geometry(
+ self,
+ media_batch,
+ condition,
+ encoded,
+ conditioned=True,
+ )
+
+ def _decode_output_state(
+ self,
+ encoded: EncodedOutputState,
+ *,
+ output_type: Literal["pil", "pt", "np"],
+ ) -> Any:
+ """Decode both components through the existing joint AV decoder."""
+ return decode_ltx2_output_state(self, encoded, output_type=output_type)
+
+ def _reduce_flow_matching_objective_values(
+ self,
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: Optional[LatentState] = None,
+ ) -> torch.Tensor:
+ """Sum official video/audio means without changing online reducers."""
+ return reduce_ltx2_flow_matching_objective_values(
+ self,
+ values,
+ state=state,
+ )
+
def _check_inputs(
self,
height: int,
@@ -586,6 +658,7 @@ def encode_video(self, videos, **kwargs):
def preprocess_func(
self,
prompt: List[str],
+ negative_prompt: Optional[Union[str, List[str]]] = None,
images: Optional[List[Union[Image.Image, List[Image.Image]]]] = None,
system_prompt: Optional[str] = None,
prompt_enhancement_seed: int = 10,
@@ -613,6 +686,7 @@ def preprocess_func(
batch = self.encode_prompt(
prompt=prompt,
+ negative_prompt=negative_prompt,
guidance_scale=guidance_scale,
audio_guidance_scale=audio_guidance_scale,
max_sequence_length=max_sequence_length,
@@ -771,6 +845,7 @@ def forward(
compute_log_prob: bool = True,
return_kwargs: List[str] = ["next_latents", "log_prob", "velocity"],
use_cross_timestep: bool = False,
+ preserve_raw_model_velocity: bool = False,
# Component-return mode, owned by ``_forward_state``
_return_components: bool = False,
**kwargs,
@@ -929,6 +1004,15 @@ def forward(
)
video_pred = video_pred.float()
audio_pred = audio_pred.float()
+ raw_video_pred = video_pred
+ raw_audio_pred = audio_pred
+ uses_x0_guidance = (
+ do_cfg
+ or do_stg
+ or do_modality_isolation
+ or guidance_rescale > 0
+ or audio_guidance_rescale > 0
+ )
# --- 2. Convert to x0-space and compute guidance deltas ---
if do_cfg:
@@ -1022,9 +1106,35 @@ def forward(
audio_x0_guided, audio_x0, guidance_rescale=audio_guidance_rescale
)
- # --- 6. Convert back to velocity for scheduler step ---
- video_pred = self.convert_x0_to_velocity(video_latents, video_x0_guided, sigma)
- audio_pred = self.convert_x0_to_velocity(audio_latents, audio_x0_guided, sigma)
+ # --- 6. Convert back only when an x0-space guidance transform ran. ---
+ # The algebraic round trip is numerically unstable near sigma=0 and is
+ # unnecessary for SFT/offline-DPO's neutral-guidance forward.
+ if uses_x0_guidance or not preserve_raw_model_velocity:
+ video_pred = self.convert_x0_to_velocity(
+ video_latents,
+ video_x0_guided,
+ sigma,
+ )
+ audio_pred = self.convert_x0_to_velocity(
+ audio_latents,
+ audio_x0_guided,
+ sigma,
+ )
+ else:
+ video_pred = raw_video_pred
+ audio_pred = raw_audio_pred
+
+ velocity_only = (
+ not compute_log_prob and next_latents is None and tuple(return_kwargs) == ("velocity",)
+ )
+ if velocity_only:
+ if _return_components:
+ return MultiModalStepOutput(
+ velocity=LatentState({"video": video_pred, "audio": audio_pred})
+ )
+ return FlowMatchEulerDiscreteSDESchedulerOutput(
+ velocity=torch.cat([video_pred, audio_pred], dim=1)
+ )
# --- 7. [I2AV] Video scheduler step with frame-slicing ---
if conditioning_mask is not None:
diff --git a/src/flow_factory/models/ltx2/ltx2_t2av.py b/src/flow_factory/models/ltx2/ltx2_t2av.py
index 66751c7fa..13e4e6fed 100644
--- a/src/flow_factory/models/ltx2/ltx2_t2av.py
+++ b/src/flow_factory/models/ltx2/ltx2_t2av.py
@@ -16,12 +16,13 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Union
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import torch
from accelerate import Accelerator
from diffusers.pipelines.ltx2.pipeline_ltx2 import LTX2Pipeline, rescale_noise_cfg
+from ...contracts import BatchCapability, GeometrySource, NegativePromptPolicy
from ...hparams import *
from ...samples import (
ComponentTimes,
@@ -46,6 +47,8 @@
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..output_state import DecodedMediaBatch, EncodedOutputState
+from ..pipeline_contracts import audio_video_output_contract
from ._common import (
LTX2_COMPONENT_ORDER,
attach_ltx2_state_masks,
@@ -61,6 +64,13 @@
split_ltx2_callback_results,
validate_ltx2_forward_state_inputs,
)
+from ._output import (
+ LTX2_OFFLINE_FORWARD_OVERRIDES,
+ LTX2AVOutputCodec,
+ decode_ltx2_output_state,
+ reduce_ltx2_flow_matching_objective_values,
+ validate_ltx2_encoded_output_geometry,
+)
logger = setup_logger(__name__)
@@ -162,11 +172,13 @@ class LTX2_T2AV_Adapter(BaseAdapter):
log_probs is the joint policy log_prob that drives policy gradient training.
"""
- output_state_codec_unavailable_reason = (
- "LTX2 offline targets require paired video/audio decoding with detected sample rates, "
- "official mel preprocessing, and exact duration alignment; the offline data plane "
- "does not yet provide that lossless audiovisual boundary"
+ component_load_dtype_defaults = {"audio_vae": torch.float32}
+ pipeline_io_contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
)
+ offline_training_forward_overrides = LTX2_OFFLINE_FORWARD_OVERRIDES
supports_diffusers_cache = True
trajectory_component_order: ClassVar[Tuple[str, ...]] = LTX2_COMPONENT_ORDER
@@ -265,6 +277,47 @@ def inference_modules(self) -> List[str]:
"""Components needed during inference and training forward."""
return ["transformer", "vae", "audio_vae", "connectors", "vocoder"]
+ def build_output_state_codec(self) -> LTX2AVOutputCodec:
+ """Declare deterministic on-the-fly encoding for paired AV targets."""
+ return LTX2AVOutputCodec(self)
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Validate target state geometry against the configured LTX2 clocks."""
+ validate_ltx2_encoded_output_geometry(
+ self,
+ media_batch,
+ condition,
+ encoded,
+ conditioned=False,
+ )
+
+ def _decode_output_state(
+ self,
+ encoded: EncodedOutputState,
+ *,
+ output_type: Literal["pil", "pt", "np"],
+ ) -> Any:
+ """Decode both components through the existing joint AV decoder."""
+ return decode_ltx2_output_state(self, encoded, output_type=output_type)
+
+ def _reduce_flow_matching_objective_values(
+ self,
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: Optional[LatentState] = None,
+ ) -> torch.Tensor:
+ """Sum official video/audio means without changing online reducers."""
+ return reduce_ltx2_flow_matching_objective_values(
+ self,
+ values,
+ state=state,
+ )
+
# ============================== Input Validation ==============================
def _check_inputs(
@@ -712,6 +765,7 @@ def forward(
return_kwargs: List[str] = ["next_latents", "log_prob", "velocity"],
# LTX-2.3 compatibility
use_cross_timestep: bool = False,
+ preserve_raw_model_velocity: bool = False,
# Component-return mode, owned by ``_forward_state``
_return_components: bool = False,
**kwargs,
@@ -873,6 +927,15 @@ def forward(
)
video_pred = video_pred.float()
audio_pred = audio_pred.float()
+ raw_video_pred = video_pred
+ raw_audio_pred = audio_pred
+ uses_x0_guidance = (
+ do_cfg
+ or do_stg
+ or do_modality_isolation
+ or guidance_rescale > 0
+ or audio_guidance_rescale > 0
+ )
# --- 2. Convert to x0-space and compute guidance deltas (pipeline L1250-1400) ---
if do_cfg:
@@ -959,9 +1022,35 @@ def forward(
audio_x0_guided, audio_x0, guidance_rescale=audio_guidance_rescale
)
- # --- 7. Convert back to velocity for scheduler step ---
- video_pred = self.convert_x0_to_velocity(video_latents, video_x0_guided, sigma)
- audio_pred = self.convert_x0_to_velocity(audio_latents, audio_x0_guided, sigma)
+ # --- 7. Convert back only when an x0-space guidance transform ran. ---
+ # The algebraic round trip is numerically unstable near sigma=0 and is
+ # unnecessary for SFT/offline-DPO's neutral-guidance forward.
+ if uses_x0_guidance or not preserve_raw_model_velocity:
+ video_pred = self.convert_x0_to_velocity(
+ video_latents,
+ video_x0_guided,
+ sigma,
+ )
+ audio_pred = self.convert_x0_to_velocity(
+ audio_latents,
+ audio_x0_guided,
+ sigma,
+ )
+ else:
+ video_pred = raw_video_pred
+ audio_pred = raw_audio_pred
+
+ velocity_only = (
+ not compute_log_prob and next_latents is None and tuple(return_kwargs) == ("velocity",)
+ )
+ if velocity_only:
+ if _return_components:
+ return MultiModalStepOutput(
+ velocity=LatentState({"video": video_pred, "audio": audio_pred})
+ )
+ return FlowMatchEulerDiscreteSDESchedulerOutput(
+ velocity=torch.cat([video_pred, audio_pred], dim=1)
+ )
# --- 8. Video: SDE scheduler step (with log_prob) ---
video_output = self.scheduler.step(
diff --git a/src/flow_factory/models/minimax_h3/__init__.py b/src/flow_factory/models/minimax_h3/__init__.py
index 2d24d6bbf..bf823ec55 100644
--- a/src/flow_factory/models/minimax_h3/__init__.py
+++ b/src/flow_factory/models/minimax_h3/__init__.py
@@ -37,7 +37,12 @@
MiniMaxH3Ref2VAAdapter,
MiniMaxH3T2VAAdapter,
)
-from .blocks import encode_h3_workflow_inputs, prepare_h3_rollout_state, run_h3_blocks
+from .blocks import (
+ encode_h3_workflow_inputs,
+ prepare_h3_condition_prefixes,
+ prepare_h3_rollout_state,
+ run_h3_blocks,
+)
from .decoding import decode_h3_targets
from .denoise import forward_h3_state, run_h3_joint_transformer, step_h3_components
from .dependency import require_minimax_h3_support
@@ -65,6 +70,7 @@
"model_time_to_framework_sigma",
"pack_audio_latents",
"pack_video_latents",
+ "prepare_h3_condition_prefixes",
"prepare_h3_rollout_state",
"require_minimax_h3_support",
"run_h3_blocks",
diff --git a/src/flow_factory/models/minimax_h3/_condition.py b/src/flow_factory/models/minimax_h3/_condition.py
new file mode 100644
index 000000000..04492d2a3
--- /dev/null
+++ b/src/flow_factory/models/minimax_h3/_condition.py
@@ -0,0 +1,99 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Runtime condition-prefix preparation for conditioned MiniMax H3 workflows."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any, ClassVar, Optional, Tuple
+
+import torch
+
+from ..condition_state import PreparedConditionState
+from .blocks import prepare_h3_condition_prefixes
+from .workflow import _normalize_layout
+
+_RUNTIME_OWNED_FIELDS = frozenset(
+ {
+ "condition_latents",
+ "audio_condition_latents",
+ "condition_prefixes",
+ "layout",
+ "position_ids",
+ "token_tags",
+ "video_indices",
+ "audio_indices",
+ "text_indices",
+ "num_condition_video_rows",
+ "num_condition_audio_rows",
+ }
+)
+
+
+@dataclass(frozen=True, slots=True)
+class MiniMaxH3ConditionStatePreparer:
+ """Realize one FL2VA/Ref2VA prefix and share it across offline arms."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("scheduler",)
+
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> PreparedConditionState:
+ """Noise cached visual conditions once and bind canonical H3 layout.
+
+ Args:
+ condition: B=1 cached H3 condition with clean condition latents.
+ generator: Optional generator consumed in official packed condition order.
+
+ Returns:
+ Input-owned condition realization shared by every offline target arm.
+ """
+ workflow = getattr(self.adapter, "workflow", None)
+ if workflow not in ("fl2va", "ref2va"):
+ raise ValueError(
+ "MiniMax H3 condition preparer requires workflow 'fl2va' or 'ref2va', "
+ f"received {workflow!r}"
+ )
+ if "condition_prefixes" in condition:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} cached condition must not contain "
+ "already-realized condition_prefixes"
+ )
+
+ layout = _normalize_layout(condition)
+ prefixes = prepare_h3_condition_prefixes(
+ self.adapter.pipeline,
+ condition,
+ workflow=workflow,
+ generator=generator,
+ )
+ static_condition = {
+ key: value for key, value in condition.items() if key not in _RUNTIME_OWNED_FIELDS
+ }
+ return PreparedConditionState(
+ condition=static_condition,
+ forward_context={
+ "condition_prefixes": prefixes,
+ "layout": layout,
+ },
+ output_context={"layout": layout},
+ )
+
+
+__all__ = ["MiniMaxH3ConditionStatePreparer"]
diff --git a/src/flow_factory/models/minimax_h3/_output.py b/src/flow_factory/models/minimax_h3/_output.py
index dff208c3f..c5a151f63 100644
--- a/src/flow_factory/models/minimax_h3/_output.py
+++ b/src/flow_factory/models/minimax_h3/_output.py
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""On-the-fly audiovisual target encoding for MiniMax H3 T2VA."""
+"""On-the-fly audiovisual target encoding for all MiniMax H3 workflows."""
from __future__ import annotations
@@ -75,7 +75,7 @@ class _H3ModelShape:
@dataclass(frozen=True, slots=True)
class MiniMaxH3AVOutputCodec:
- """Encode one configured T2VA video/audio target into packed H3 rows."""
+ """Encode one configured H3 video/audio target into packed target rows."""
adapter: Any
required_components: ClassVar[Tuple[str, ...]] = ("vae", "audio_vae")
@@ -89,29 +89,28 @@ def encode_output_state(
"""Encode the exact ``(video, audio)`` target sequence for one sample.
Diffusers defines H3's conditioning encoder, but not an offline target
- encoder. Video posterior sampling is therefore a framework policy inferred
- from latent-diffusion training objectives. In particular, the condition-only
- float16 rounding step is intentionally not applied to clean targets. Audio
- targets use the posterior mode because the official H3 pipeline never samples
- the audio posterior. Both components are normalized and packed exactly like
- generated target rows.
+ encoder. Both target posteriors therefore use their deterministic modes,
+ matching the released H3 SFT data flow. The condition-only float16 rounding
+ step is intentionally not applied to clean targets. Both components are
+ normalized and packed exactly like generated target rows.
Args:
media_batch: One decoded video/audio pair.
- condition: Cached T2VA condition and its authoritative packed layout.
- generator: Optional generator forwarded only to video posterior sampling.
+ condition: Cached H3 condition and its authoritative packed layout.
+ generator: Accepted for the shared codec protocol; H3 target modes are
+ deterministic and do not consume it.
Returns:
Detached clean video/audio rows plus the output-owned decode context.
"""
if len(media_batch) != 1:
raise ValueError(
- f"MiniMax H3 T2VA output encoding requires B=1, received B={len(media_batch)}"
+ f"MiniMax H3 output encoding requires B=1, received B={len(media_batch)}"
)
candidate = media_batch[0]
if len(candidate) != 2:
raise ValueError(
- "MiniMax H3 T2VA output codec expected exact (video, audio) media, "
+ "MiniMax H3 output codec expected exact (video, audio) media, "
f"received {len(candidate)} items"
)
video_media, audio_media = candidate
@@ -126,11 +125,8 @@ def encode_output_state(
height=geometry["height"],
width=geometry["width"],
)
- video_latents = encode_h3_target_video(
- self.adapter,
- video_pixels,
- generator=generator,
- )
+ del generator
+ video_latents = encode_h3_target_video(self.adapter, video_pixels)
expected_video_shape = (
1,
model_shape.video_latent_channels,
@@ -180,7 +176,7 @@ def encode_output_state(
}
)
validate_target_state(clean_state)
- _validate_h3_t2va_input_layout(condition, clean_state)
+ _validate_h3_input_layout(condition, clean_state)
frame_rate = float(self.adapter.pipeline.fps)
signature = GeometrySignature(
media=(
@@ -207,7 +203,7 @@ def encode_output_state(
def resolve_h3_output_geometry(adapter: Any, condition: Mapping[str, Any]) -> Dict[str, int]:
- """Resolve and validate configured geometry from the cached T2VA condition.
+ """Resolve and validate configured geometry from the cached H3 condition.
Args:
adapter: Active MiniMax H3 adapter with configured pipeline components.
@@ -458,22 +454,17 @@ def prepare_h3_target_video(
def encode_h3_target_video(
adapter: Any,
pixel_values: torch.Tensor,
- *,
- generator: Optional[torch.Generator],
) -> torch.Tensor:
- """Apply the framework's sampled-posterior policy for clean H3 targets.
+ """Encode clean H3 targets through the deterministic video posterior mode.
Diffusers specifies a fixed-seed sample followed by float16 rounding only for
- H3 *conditions*. Offline clean targets instead use the caller's generator and
- retain the sampled posterior in float32 before normalization. This distinction
- is an explicit framework inference from latent training, not an official H3
- target-encoding recipe.
+ H3 *conditions*. Its released pipeline has no offline target encoder, so this
+ path follows the H3 SFT reference data flow: take the posterior mean/mode and
+ retain float32 precision before normalization.
Args:
adapter: Active MiniMax H3 adapter exposing the video VAE.
pixel_values: Normalized-shape pixels ``(1, 3, F, H, W)`` in ``[0, 1]``.
- generator: Optional posterior sampling generator.
-
Returns:
Normalized video latents shaped ``(1, 24, F', H', W')``.
"""
@@ -500,8 +491,7 @@ def encode_h3_target_video(
encoded = vae.encode(normalized_pixels)
latents = retrieve_vae_latents(
encoded,
- sample_mode="sample",
- generator=generator,
+ sample_mode="argmax",
source="MiniMax H3 target video",
).to(torch.float32)
latent_mean, latent_std = _latent_statistics(
@@ -609,11 +599,11 @@ def encode_h3_target_audio(adapter: Any, waveform: torch.Tensor) -> torch.Tensor
return (latents - latent_mean) / latent_std
-def _validate_h3_t2va_input_layout(
+def _validate_h3_input_layout(
condition: Mapping[str, Any],
clean_state: LatentState,
) -> None:
- """Validate the authoritative flat T2VA layout retained in input conditions."""
+ """Validate target rows against an authoritative H3 input layout."""
layout = _normalize_layout(condition)
missing = tuple(field for field in _LAYOUT_FIELDS if field not in layout)
if missing:
@@ -629,7 +619,7 @@ def _validate_h3_t2va_input_layout(
or position_ids.dtype != torch.float64
):
raise ValueError(
- "MiniMax H3 T2VA position_ids expected float64 shape (N,3), "
+ "MiniMax H3 position_ids expected float64 shape (N,3), "
f"received {type(position_ids).__name__}/{getattr(position_ids, 'shape', None)}/"
f"{getattr(position_ids, 'dtype', None)}"
)
@@ -639,37 +629,39 @@ def _validate_h3_t2va_input_layout(
or token_tags.dtype != torch.long
):
raise ValueError(
- "MiniMax H3 T2VA token_tags expected one-dimensional torch.long, "
+ "MiniMax H3 token_tags expected one-dimensional torch.long, "
f"received {type(token_tags).__name__}/{getattr(token_tags, 'shape', None)}/"
f"{getattr(token_tags, 'dtype', None)}"
)
for field, values in zip(("video_indices", "audio_indices", "text_indices"), index_tensors):
if not isinstance(values, torch.Tensor) or values.ndim != 1 or values.dtype != torch.long:
raise ValueError(
- f"MiniMax H3 T2VA {field} expected one-dimensional torch.long, "
+ f"MiniMax H3 {field} expected one-dimensional torch.long, "
f"received {type(values).__name__}/{getattr(values, 'shape', None)}/"
f"{getattr(values, 'dtype', None)}"
)
for component in ("video", "audio"):
count_field = f"num_condition_{component}_rows"
- if layout[count_field] != 0:
+ condition_rows = layout[count_field]
+ if condition_rows < 0:
raise ValueError(
- "MiniMax H3 T2VA offline layout requires no condition rows, "
- f"received {count_field}={layout[count_field]}"
+ f"MiniMax H3 offline layout requires non-negative {count_field}, "
+ f"received {condition_rows}"
)
component_indices = layout[f"{component}_indices"]
- expected_rows = clean_state.components[component].shape[1]
- if component_indices.numel() != expected_rows:
+ target_rows = clean_state.components[component].shape[1]
+ expected_total_rows = condition_rows + target_rows
+ if component_indices.numel() != expected_total_rows:
raise ValueError(
- f"MiniMax H3 T2VA {component} layout expected {expected_rows} target rows, "
- f"received {component_indices.numel()} indices"
+ f"MiniMax H3 {component} layout expected {condition_rows} condition + "
+ f"{target_rows} target rows, received {component_indices.numel()} indices"
)
sequence_length = sum(values.numel() for values in index_tensors)
if position_ids.shape[0] != sequence_length or token_tags.numel() != sequence_length:
raise ValueError(
- "MiniMax H3 T2VA flat layout sequence lengths disagree: "
+ "MiniMax H3 flat layout sequence lengths disagree: "
f"indices={sequence_length}, position_ids={position_ids.shape[0]}, "
f"token_tags={token_tags.numel()}"
)
@@ -679,9 +671,7 @@ def _validate_h3_t2va_input_layout(
*(values.device for values in index_tensors),
}
if len(devices) != 1:
- raise ValueError(
- f"MiniMax H3 T2VA flat layout tensors must share one device, got {devices}"
- )
+ raise ValueError(f"MiniMax H3 flat layout tensors must share one device, got {devices}")
permutation = torch.cat(index_tensors).sort().values
expected_permutation = torch.arange(
sequence_length,
@@ -689,9 +679,7 @@ def _validate_h3_t2va_input_layout(
device=permutation.device,
)
if not torch.equal(permutation, expected_permutation):
- raise ValueError(
- "MiniMax H3 T2VA video/audio/text indices must partition the packed sequence"
- )
+ raise ValueError("MiniMax H3 video/audio/text indices must partition the packed sequence")
def validate_h3_encoded_output_geometry(
@@ -700,7 +688,7 @@ def validate_h3_encoded_output_geometry(
condition: Mapping[str, Any],
encoded: EncodedOutputState,
) -> None:
- """Prove codec geometry and contexts agree with the cached T2VA layout.
+ """Prove codec geometry and contexts agree with the cached H3 layout.
Args:
adapter: Active MiniMax H3 adapter.
@@ -781,10 +769,10 @@ def validate_h3_encoded_output_geometry(
if encoded.forward_context:
raise ValueError(
- "MiniMax H3 T2VA output codec must not duplicate input-owned layout/prefix fields, "
+ "MiniMax H3 output codec must not duplicate input-owned layout/prefix fields, "
f"received keys={tuple(encoded.forward_context)}"
)
- _validate_h3_t2va_input_layout(condition, encoded.clean_state)
+ _validate_h3_input_layout(condition, encoded.clean_state)
def _resolve_h3_model_shape(adapter: Any) -> _H3ModelShape:
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index 6c0a001c3..a9c7e74bc 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -21,7 +21,13 @@
from ...contracts import (
BatchCapability,
GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaRule,
+ MediaFormat,
+ MediaType,
NegativePromptPolicy,
+ RateRequirement,
)
from ...samples import (
ComponentTimes,
@@ -34,9 +40,14 @@
from ..abc import BaseAdapter
from ..checkpointing import CheckpointUnit
from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
-from ..pipeline_contracts import audio_video_output_contract
+from ..pipeline_contracts import (
+ IMAGE_FORMAT,
+ VIDEO_FORMAT_OPTIONAL_FPS,
+ audio_video_output_contract,
+)
from ..runtime import ModularPipelineRuntime
from ._common import apply_forward_process_noise, draw_forward_process_noise
+from ._condition import MiniMaxH3ConditionStatePreparer
from ._output import MiniMaxH3AVOutputCodec, validate_h3_encoded_output_geometry
from .workflow import (
build_h3_component_runtime,
@@ -53,7 +64,12 @@
)
_H3_PREPROCESS_CACHE_FIELDS = frozenset({"height", "width", "num_frames"})
-_H3_PREPROCESS_CACHE_VERSION = "minimax-h3-v1"
+_H3_PREPROCESS_CACHE_VERSION = "minimax-h3-v2"
+_H3_OPTIONAL_AUDIO_REFERENCE_FORMAT = MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.OPTIONAL,
+)
class _MiniMaxH3WorkflowAdapter:
@@ -132,6 +148,46 @@ def _init_target_module_map(self) -> Dict[str, Union[List[str], None]]:
def preprocess_func(self, **kwargs: Any) -> Dict[str, Any]:
return preprocess_h3_workflow(self, **kwargs)
+ def build_condition_state_preparer(
+ self,
+ ) -> Optional[MiniMaxH3ConditionStatePreparer]:
+ """Declare runtime prefix preparation only for conditioned workflows."""
+ if self.workflow == "t2va":
+ return None
+ return MiniMaxH3ConditionStatePreparer(self)
+
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Declare the shared audiovisual target codec without loading components."""
+ return MiniMaxH3AVOutputCodec(self)
+
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Require encoded AV rows to match this workflow's cached layout."""
+ validate_h3_encoded_output_geometry(self, media_batch, condition, encoded)
+
+ def _decode_output_state(
+ self,
+ encoded: EncodedOutputState,
+ *,
+ output_type: Literal["pil", "pt", "np"],
+ ) -> Any:
+ """Decode both H3 target components through the existing decoder."""
+ geometry = encoded.decode_context.get("geometry")
+ if not isinstance(geometry, Mapping):
+ raise TypeError(
+ "MiniMax H3 decode_context requires a geometry mapping, "
+ f"received {type(geometry).__name__}: {geometry!r}"
+ )
+ return self.decode_latents(
+ encoded.clean_state,
+ geometry=geometry,
+ output_type=output_type,
+ )
+
def build_training_component_times(
self,
primary_timesteps: torch.Tensor,
@@ -157,6 +213,21 @@ def apply_forward_process_noise(
) -> NoisedState:
return apply_forward_process_noise(clean_state, times, noise)
+ def _reduce_flow_matching_objective_values(
+ self,
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: Optional[LatentState] = None,
+ ) -> torch.Tensor:
+ """Sum the official video and audio means for offline flow matching.
+
+ H3's audiovisual objective gives each modality its own mean-squared-error
+ term. This objective-specific hook intentionally leaves the globally
+ element-weighted reducer used by online likelihood objectives unchanged.
+ """
+ component_means = self.reduce_component_latent_values(values, state=state)
+ return component_means["video"] + component_means["audio"]
+
def decode_latents(self, latents: Any, **kwargs: Any) -> Any:
return decode_h3_adapter_latents(self, latents, **kwargs)
@@ -221,50 +292,24 @@ class MiniMaxH3T2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"audio_vae",
]
- def build_output_state_codec(self) -> OutputStateCodec:
- """Declare the configured audiovisual target codec without loading components.
-
- Returns:
- Immutable MiniMax H3 audiovisual output codec declaration.
- """
- return MiniMaxH3AVOutputCodec(self)
-
- def _validate_encoded_output_geometry(
- self,
- media_batch: DecodedMediaBatch,
- condition: Mapping[str, Any],
- encoded: EncodedOutputState,
- ) -> None:
- """Require encoded rows and rate metadata to match cached T2VA geometry."""
- validate_h3_encoded_output_geometry(self, media_batch, condition, encoded)
-
- def _decode_output_state(
- self,
- encoded: EncodedOutputState,
- *,
- output_type: Literal["pil", "pt", "np"],
- ) -> Any:
- """Decode the two-component target state through H3's existing decoder."""
- geometry = encoded.decode_context.get("geometry")
- if not isinstance(geometry, Mapping):
- raise TypeError(
- "MiniMax H3 decode_context requires a geometry mapping, "
- f"received {type(geometry).__name__}: {geometry!r}"
- )
- return self.decode_latents(
- encoded.clean_state,
- geometry=geometry,
- output_type=output_type,
- )
-
class MiniMaxH3FL2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 first/last-frame partition."""
- output_state_codec_unavailable_reason = (
- "MiniMax H3 conditioned offline forward requires a shared, reproducible "
- "conditioned-prefix binder; paired offline-DPO arms must reuse identical "
- "condition posterior noise"
+ pipeline_io_contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_rules=(
+ InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=1,
+ max_count=2,
+ slots=("first_frame", "last_frame"),
+ ),
+ ),
+ input_binding=InputMediaBinding.GROUPED_BY_TYPE,
+ input_order=InputMediaOrder.WITHIN_TYPE,
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
)
workflow: ClassVar[str] = "fl2va"
@@ -287,10 +332,24 @@ class MiniMaxH3FL2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
class MiniMaxH3Ref2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"""Load the workflow-pruned MiniMax H3 omni-reference partition."""
- output_state_codec_unavailable_reason = (
- "MiniMax H3 conditioned offline forward requires a shared, reproducible "
- "conditioned-prefix binder; paired offline-DPO arms must reuse identical "
- "condition posterior noise"
+ pipeline_io_contract = audio_video_output_contract(
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ input_rules=(
+ InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=9),
+ InputMediaRule(format=VIDEO_FORMAT_OPTIONAL_FPS, min_count=0, max_count=3),
+ InputMediaRule(
+ format=_H3_OPTIONAL_AUDIO_REFERENCE_FORMAT,
+ min_count=0,
+ max_count=3,
+ ),
+ ),
+ input_binding=InputMediaBinding.ORDERED_REFERENCES,
+ input_order=InputMediaOrder.GLOBAL,
+ min_input_media_count=1,
+ max_input_media_count=12,
+ required_any_input_types=(MediaType.IMAGE, MediaType.VIDEO),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
)
workflow: ClassVar[str] = "ref2va"
diff --git a/src/flow_factory/models/minimax_h3/blocks.py b/src/flow_factory/models/minimax_h3/blocks.py
index 0810123cd..908a769e5 100644
--- a/src/flow_factory/models/minimax_h3/blocks.py
+++ b/src/flow_factory/models/minimax_h3/blocks.py
@@ -41,21 +41,14 @@
)
ENCODE_COMMON_FIELDS = (
"prompt_embeds",
- "text_token_tags",
*GEOMETRY_FIELDS,
*LAYOUT_FIELDS,
)
ENCODE_WORKFLOW_FIELDS = {
- "t2va": (*ENCODE_COMMON_FIELDS, "keyframe_anchors"),
- "fl2va": (
- *ENCODE_COMMON_FIELDS,
- "keyframes",
- "keyframe_anchors",
- "condition_latents",
- ),
+ "t2va": ENCODE_COMMON_FIELDS,
+ "fl2va": (*ENCODE_COMMON_FIELDS, "condition_latents"),
"ref2va": (
*ENCODE_COMMON_FIELDS,
- "normalized_references",
"condition_latents",
"audio_condition_latents",
),
@@ -169,14 +162,36 @@ def prepare_h3_rollout_state(
Returns:
Target-only state and immutable video/audio prefixes.
"""
- symbols = require_minimax_h3_support()
_validate_workflow(workflow)
+ symbols = require_minimax_h3_support()
values = dict(cached_values)
values.update(generator=generator, latents=latents, audio_latents=audio_latents)
- block_types = []
- if workflow != "t2va":
- block_types.append(symbols.PrepareConditionLatentsStep)
- block_types.append(symbols.PrepareLatentsStep)
+ video_count = _condition_count(cached_values, workflow, "video")
+ audio_count = _condition_count(cached_values, workflow, "audio")
+ prefixes: Dict[str, torch.Tensor]
+ if workflow == "t2va":
+ if video_count or audio_count:
+ raise ValueError(
+ "MiniMax H3 workflow='t2va' requires zero condition rows, "
+ f"received video={video_count}, audio={audio_count}"
+ )
+ prefixes = {}
+ else:
+ prefixes = prepare_h3_condition_prefixes(
+ pipeline,
+ cached_values,
+ workflow=workflow,
+ generator=generator,
+ )
+ values.update(
+ condition_rows=prefixes["video"][0],
+ num_condition_video_rows=video_count,
+ num_condition_audio_rows=audio_count,
+ )
+ if workflow == "ref2va":
+ values["audio_condition_latents"] = [] if audio_count == 0 else [prefixes["audio"][0]]
+
+ block_types = [symbols.PrepareLatentsStep]
if workflow == "fl2va":
block_types.append(symbols.FL2VAPrepareLatentsStep)
elif workflow == "ref2va":
@@ -190,8 +205,6 @@ def prepare_h3_rollout_state(
)
video_rows = _as_batched_rows(output["latents"], 96, workflow, "latents")
audio_rows = _as_batched_rows(output["audio_latents"], 32, workflow, "audio_latents")
- video_count = _condition_count(cached_values, workflow, "video")
- audio_count = _condition_count(cached_values, workflow, "audio")
_validate_rollout_row_counts(
cached_values,
workflow,
@@ -219,6 +232,100 @@ def prepare_h3_rollout_state(
return targets, prefixes
+def prepare_h3_condition_prefixes(
+ pipeline: Any,
+ cached_values: Mapping[str, Any],
+ *,
+ workflow: str,
+ generator: Optional[torch.Generator] = None,
+) -> Dict[str, torch.Tensor]:
+ """Realize one immutable H3 condition prefix from cached clean latents.
+
+ The helper owns the only runtime condition-noise draw. Online rollout calls it
+ before generated video/audio noise, while offline SFT and DPO call it once per
+ batch and reuse the returned tensors for every target and policy/reference arm.
+
+ Args:
+ pipeline: Adapter-owned modular pipeline with the canonical scheduler.
+ cached_values: Cached layout and clean visual/audio condition latents.
+ workflow: Conditioned H3 workflow, ``fl2va`` or ``ref2va``.
+ generator: Optional request generator consumed in packed visual order.
+
+ Returns:
+ Batched immutable video/audio prefix rows in model packing order.
+ """
+ _validate_workflow(workflow)
+ if workflow == "t2va":
+ raise ValueError("MiniMax H3 T2VA has no runtime condition prefix to realize")
+
+ symbols = require_minimax_h3_support()
+ video_count = _condition_count(cached_values, workflow, "video")
+ audio_count = _condition_count(cached_values, workflow, "audio")
+ condition_latents = _normalize_condition_tensor_list(
+ cached_values.get("condition_latents"),
+ workflow=workflow,
+ field="condition_latents",
+ item_rank=5,
+ )
+ values = dict(cached_values)
+ values.update(
+ generator=generator,
+ num_condition_video_rows=video_count,
+ condition_latents=condition_latents,
+ )
+ output = run_h3_blocks(
+ pipeline,
+ [symbols.PrepareConditionLatentsStep()],
+ values,
+ requested_outputs=("condition_rows",),
+ workflow=workflow,
+ )
+ video_rows = _as_batched_rows(
+ output["condition_rows"],
+ 96,
+ workflow,
+ "condition_rows",
+ )
+ _validate_condition_prefix_rows(
+ cached_values,
+ workflow,
+ "video",
+ prefix_rows=video_rows.shape[1],
+ condition_row_count=video_count,
+ )
+
+ if workflow == "fl2va":
+ if audio_count:
+ raise ValueError(
+ "MiniMax H3 FL2VA requires zero audio condition rows, " f"received {audio_count}"
+ )
+ audio_rows = video_rows.new_empty((1, 0, 32))
+ else:
+ audio_latents = _normalize_condition_tensor_list(
+ cached_values.get("audio_condition_latents", []),
+ workflow=workflow,
+ field="audio_condition_latents",
+ item_rank=2,
+ )
+ if audio_latents:
+ audio_rows = _as_batched_rows(
+ torch.cat([rows.to(video_rows.device) for rows in audio_latents], dim=0),
+ 32,
+ workflow,
+ "audio_condition_latents",
+ )
+ else:
+ audio_rows = video_rows.new_empty((1, 0, 32))
+ _validate_condition_prefix_rows(
+ cached_values,
+ workflow,
+ "audio",
+ prefix_rows=audio_rows.shape[1],
+ condition_row_count=audio_count,
+ )
+ return {"video": video_rows, "audio": audio_rows}
+
+
def _validate_workflow(workflow: str) -> None:
if workflow not in WORKFLOWS:
raise ValueError(f"expected MiniMax H3 workflow in {WORKFLOWS}, received {workflow!r}")
@@ -227,6 +334,20 @@ def _validate_workflow(workflow: str) -> None:
def _condition_count(values: Mapping[str, Any], workflow: str, component: str) -> int:
field = f"num_condition_{component}_rows"
count = values.get(field, 0)
+ if isinstance(count, (list, tuple)):
+ if len(count) != 1:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} field={field!r} expected B=1, "
+ f"received length={len(count)}"
+ )
+ count = count[0]
+ if isinstance(count, torch.Tensor):
+ if count.numel() != 1:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} field={field!r} expected one scalar, "
+ f"received shape={tuple(count.shape)}"
+ )
+ count = count.item()
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
raise ValueError(
f"MiniMax H3 workflow={workflow!r} field={field!r} expected non-negative int, "
@@ -244,13 +365,7 @@ def _validate_rollout_row_counts(
condition_row_count: int,
) -> None:
index_field = f"{component}_indices"
- indices = values.get(index_field)
- if not isinstance(indices, torch.Tensor) or indices.ndim != 1:
- raise ValueError(
- f"MiniMax H3 workflow={workflow!r} component={component!r} field={index_field!r} "
- f"actual={getattr(indices, 'shape', type(indices).__name__)} "
- "expected=one-dimensional Tensor"
- )
+ indices = _normalize_layout_indices(values.get(index_field), workflow, index_field)
expected_full_rows = indices.numel()
if full_row_count != expected_full_rows:
raise ValueError(
@@ -267,6 +382,97 @@ def _validate_rollout_row_counts(
)
+def _validate_condition_prefix_rows(
+ values: Mapping[str, Any],
+ workflow: str,
+ component: str,
+ *,
+ prefix_rows: int,
+ condition_row_count: int,
+) -> None:
+ index_field = f"{component}_indices"
+ indices = _normalize_layout_indices(values.get(index_field), workflow, index_field)
+ if prefix_rows != condition_row_count:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} component={component!r} "
+ f"field='condition_row_count' actual={prefix_rows} expected={condition_row_count}"
+ )
+ if condition_row_count > indices.numel():
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} component={component!r} "
+ f"field='condition_row_count' actual={condition_row_count} "
+ f"expected_at_most={indices.numel()}"
+ )
+
+
+def _normalize_layout_indices(
+ value: Any,
+ workflow: str,
+ field: str,
+) -> torch.Tensor:
+ if not isinstance(value, torch.Tensor):
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} field={field!r} expected Tensor, "
+ f"received {type(value).__name__}"
+ )
+ if value.ndim == 2 and value.shape[0] == 1:
+ value = value[0]
+ if value.ndim != 1:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} field={field!r} expected shape (N,) or "
+ f"collated (B=1,N), received {tuple(value.shape)}"
+ )
+ return value
+
+
+def _normalize_condition_tensor_list(
+ value: Any,
+ *,
+ workflow: str,
+ field: str,
+ item_rank: int,
+) -> list[torch.Tensor]:
+ """Normalize Arrow/HF B=1 list and stacked-tensor representations."""
+ if isinstance(value, tuple):
+ value = list(value)
+ if isinstance(value, list) and len(value) == 1 and isinstance(value[0], (list, tuple)):
+ value = list(value[0])
+
+ if isinstance(value, list) and len(value) == 1 and isinstance(value[0], torch.Tensor):
+ first = value[0]
+ if first.ndim == item_rank + 1:
+ value = first
+ if isinstance(value, torch.Tensor):
+ if value.ndim == item_rank:
+ tensors = [value]
+ else:
+ if value.ndim == item_rank + 2 and value.shape[0] == 1:
+ value = value[0]
+ if value.ndim != item_rank + 1:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} field={field!r} expected one "
+ f"rank-{item_rank} tensor or a B=1/stacked container, received "
+ f"shape={tuple(value.shape)}"
+ )
+ tensors = list(torch.unbind(value, dim=0))
+ elif isinstance(value, list):
+ tensors = value
+ else:
+ raise TypeError(
+ f"MiniMax H3 workflow={workflow!r} field={field!r} expected tensor/list, "
+ f"received {type(value).__name__}"
+ )
+
+ for index, tensor in enumerate(tensors):
+ if not isinstance(tensor, torch.Tensor) or tensor.ndim != item_rank:
+ raise ValueError(
+ f"MiniMax H3 workflow={workflow!r} field={field!r}[{index}] expected "
+ f"rank-{item_rank} Tensor, received "
+ f"{type(tensor).__name__}/{getattr(tensor, 'shape', None)}"
+ )
+ return tensors
+
+
def _as_batched_rows(rows: torch.Tensor, width: int, workflow: str, field: str) -> torch.Tensor:
if not isinstance(rows, torch.Tensor):
raise TypeError(
diff --git a/src/flow_factory/models/minimax_h3/workflow.py b/src/flow_factory/models/minimax_h3/workflow.py
index abc53f797..c0702da5d 100644
--- a/src/flow_factory/models/minimax_h3/workflow.py
+++ b/src/flow_factory/models/minimax_h3/workflow.py
@@ -171,10 +171,11 @@ def preprocess_h3_workflow(adapter: Any, **kwargs: Any) -> Dict[str, Any]:
"num_frames": kwargs["num_frames"],
}
if adapter.workflow == "fl2va":
- images = _validate_fl2va_condition_images(kwargs, "preprocess")
- values["image"] = images[0]
- if len(images) == 2:
- values["last_image"] = images[1]
+ first_image, last_image = _validate_fl2va_condition_images(kwargs, "preprocess")
+ if first_image is not None:
+ values["image"] = first_image
+ if last_image is not None:
+ values["last_image"] = last_image
elif adapter.workflow == "ref2va":
references = _single_outer_value(kwargs.get("references"), "references", adapter.workflow)
values["references"] = _build_pinned_references(references)
@@ -214,8 +215,18 @@ def infer_h3_workflow(adapter: Any, **kwargs: Any) -> List[Any]:
_validate_public_no_cfg_inputs(adapter.workflow, kwargs, "inference")
_validate_workflow_media_inputs(adapter.workflow, kwargs, "inference")
condition_images = None
+ condition_image_slots = None
if adapter.workflow == "fl2va":
- condition_images = _validate_fl2va_condition_images(kwargs, "inference")
+ first_image, last_image = _validate_fl2va_condition_images(kwargs, "inference")
+ condition_images = tuple(image for image in (first_image, last_image) if image is not None)
+ condition_image_slots = tuple(
+ slot
+ for slot, image in (
+ ("first_frame", first_image),
+ ("last_frame", last_image),
+ )
+ if image is not None
+ )
prompt = kwargs.get("prompt")
prompt_value = _single_outer_value(prompt, "prompt", adapter.workflow)
prompt_embeds = kwargs["prompt_embeds"]
@@ -420,6 +431,11 @@ def infer_h3_workflow(adapter: Any, **kwargs: Any) -> List[Any]:
},
"layout": layout,
"geometry": geometry,
+ **(
+ {}
+ if condition_image_slots is None
+ else {"condition_image_slots": condition_image_slots}
+ ),
},
)
return [sample]
@@ -732,23 +748,63 @@ def _validate_public_no_cfg_inputs(workflow: str, values: Mapping[str, Any], bou
)
-def _validate_fl2va_condition_images(values: Mapping[str, Any], boundary: str) -> Sequence[Any]:
+def _validate_fl2va_condition_images(
+ values: Mapping[str, Any],
+ boundary: str,
+) -> tuple[Any | None, Any | None]:
+ direct_first = values.get("image")
+ direct_last = values.get("last_image")
outer_images = values.get("images")
if outer_images is None:
outer_images = values.get("condition_images")
+ if (direct_first is not None or direct_last is not None) and outer_images is not None:
+ raise ValueError(
+ "MiniMax H3 workflow='fl2va' cannot combine direct image/last_image "
+ "arguments with grouped images"
+ )
+ if direct_first is not None or direct_last is not None:
+ return direct_first, direct_last
+
images = _single_outer_value(outer_images, "images", "fl2va")
if not isinstance(images, (list, tuple)) or not 1 <= len(images) <= 2:
raise ValueError(
f"MiniMax H3 workflow='fl2va' public {boundary} field='images' expected "
f"one or two ordered images, received {images!r}"
)
- return images
+ outer_slots = values.get("image_slots")
+ if outer_slots is None:
+ slots = ("first_frame", "last_frame")[: len(images)]
+ else:
+ slots = _single_outer_value(outer_slots, "image_slots", "fl2va")
+ if not isinstance(slots, (list, tuple)) or len(slots) != len(images):
+ raise ValueError(
+ f"MiniMax H3 workflow='fl2va' public {boundary} field='image_slots' "
+ f"expected {len(images)} slot(s), received {slots!r}"
+ )
+ slots = tuple(slots)
+ if len(set(slots)) != len(slots) or any(
+ slot not in ("first_frame", "last_frame") for slot in slots
+ ):
+ raise ValueError(
+ f"MiniMax H3 workflow='fl2va' public {boundary} field='image_slots' "
+ f"expected unique first_frame/last_frame values, received {slots!r}"
+ )
+ bound = dict(zip(slots, images))
+ return bound.get("first_frame"), bound.get("last_frame")
def _validate_workflow_media_inputs(
workflow: str, values: Mapping[str, Any], boundary: str
) -> None:
- media_fields = ("images", "condition_images", "videos", "audios", "references")
+ media_fields = (
+ "images",
+ "condition_images",
+ "image",
+ "last_image",
+ "videos",
+ "audios",
+ "references",
+ )
present = {
field for field in media_fields if field in values and _media_value_present(values[field])
}
@@ -757,7 +813,14 @@ def _validate_workflow_media_inputs(
f"MiniMax H3 workflow='t2va' {boundary} rejects media fields={tuple(sorted(present))}"
)
if workflow == "ref2va":
- generic = present & {"images", "condition_images", "videos", "audios"}
+ generic = present & {
+ "images",
+ "condition_images",
+ "image",
+ "last_image",
+ "videos",
+ "audios",
+ }
if generic:
raise ValueError(
f"MiniMax H3 workflow='ref2va' {boundary} rejects generic media "
diff --git a/src/flow_factory/models/pipeline_contracts.py b/src/flow_factory/models/pipeline_contracts.py
index 3903811d4..f39bfe6b1 100644
--- a/src/flow_factory/models/pipeline_contracts.py
+++ b/src/flow_factory/models/pipeline_contracts.py
@@ -110,6 +110,8 @@ def video_output_contract(
negative_prompt: NegativePromptPolicy,
input_image_min_count: Optional[int] = None,
input_image_max_count: Optional[int] = None,
+ input_image_slots: Tuple[str, ...] = (),
+ required_input_image_slots: Tuple[str, ...] = (),
output_fps: RateRequirement = RateRequirement.OPTIONAL,
geometry_source: GeometrySource = GeometrySource.OUTPUT_MEDIA,
batch_capability: BatchCapability = BatchCapability.UNIFORM,
@@ -120,6 +122,8 @@ def video_output_contract(
negative_prompt: Whether negative prompts are unsupported, optional, or required.
input_image_min_count: Minimum condition-image count, or ``None`` for no image input.
input_image_max_count: Maximum condition-image count when an image rule is present.
+ input_image_slots: Semantic image argument slots in positional fallback order.
+ required_input_image_slots: Slots that every valid request must fill.
output_fps: Whether target video frame rate metadata is required.
geometry_source: Boundary that determines output geometry.
batch_capability: Whether the adapter accepts uniform batches or one sample only.
@@ -134,6 +138,8 @@ def video_output_contract(
format=IMAGE_FORMAT,
min_count=input_image_min_count,
max_count=input_image_max_count,
+ slots=input_image_slots,
+ required_slots=required_input_image_slots,
),
)
video_format = MediaFormat(
@@ -164,6 +170,9 @@ def audio_video_output_contract(
input_rules: Tuple[InputMediaRule, ...] = (),
input_binding: InputMediaBinding = InputMediaBinding.GROUPED_BY_TYPE,
input_order: InputMediaOrder = InputMediaOrder.INSENSITIVE,
+ min_input_media_count: Optional[int] = None,
+ max_input_media_count: Optional[int] = None,
+ required_any_input_types: Tuple[MediaType, ...] = (),
output_fps: RateRequirement = RateRequirement.REQUIRED,
output_sample_rate: RateRequirement = RateRequirement.REQUIRED,
geometry_source: GeometrySource = GeometrySource.OUTPUT_MEDIA,
@@ -173,15 +182,17 @@ def audio_video_output_contract(
Supplying explicit input rules keeps this constructor neutral to how a model
binds conditions: prompt-only, grouped image conditions, and globally ordered
- heterogeneous references all use the same output contract. Cross-type total
- cardinality constraints remain adapter-owned because ``InputMediaSpec``
- represents per-type bounds only.
+ heterogeneous references all use the same output contract. Aggregate count
+ and required-any-type constraints cover cross-modality request invariants.
Args:
negative_prompt: Whether negative prompts are unsupported, optional, or required.
input_rules: Canonically ordered per-type input-media rules.
input_binding: How input media are projected into model-facing arguments.
input_order: Which input-media ordering carries semantic meaning.
+ min_input_media_count: Optional minimum count across all input modalities.
+ max_input_media_count: Optional maximum count across all input modalities.
+ required_any_input_types: Media types of which at least one must be present.
output_fps: Whether target-video frame-rate metadata is accepted or required.
output_sample_rate: Whether target-audio sample-rate metadata is accepted or required.
geometry_source: Boundary that determines aligned output geometry.
@@ -205,6 +216,9 @@ def audio_video_output_contract(
rules=input_rules,
binding=input_binding,
order=input_order,
+ min_total_count=min_input_media_count,
+ max_total_count=max_input_media_count,
+ required_any_types=required_any_input_types,
),
negative_prompt=negative_prompt,
output_media=OutputMediaSequence(items=(video_format, audio_format)),
diff --git a/src/flow_factory/models/wan/_conditioning.py b/src/flow_factory/models/wan/_conditioning.py
new file mode 100644
index 000000000..71556ab1d
--- /dev/null
+++ b/src/flow_factory/models/wan/_conditioning.py
@@ -0,0 +1,527 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Wan image-to-video condition realization shared by offline and rollout paths."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any, ClassVar, Optional, Tuple
+
+import numpy as np
+import torch
+from PIL import Image
+
+from ...utils.image import is_image, is_image_batch, is_multi_image_batch, standardize_image_batch
+from ..condition_state import PreparedConditionState
+from ..configured_image_output import retrieve_vae_latents
+from ._output import configured_wan_video_output_geometry, normalize_wan_video_latents
+
+WanConditionImageRows = Tuple[Tuple[Image.Image, ...], ...]
+
+
+@dataclass(frozen=True, slots=True)
+class WanI2VConditionTensors:
+ """Hold the transformer condition channels and optional expanded-time mask."""
+
+ condition: torch.Tensor
+ first_frame_mask: Optional[torch.Tensor]
+
+
+def normalize_wan_i2v_image_rows(
+ images: Any,
+ *,
+ expected_batch_size: Optional[int] = None,
+) -> WanConditionImageRows:
+ """Normalize first/optional-last images while preserving within-sample order."""
+ if expected_batch_size is not None and (
+ type(expected_batch_size) is not int or expected_batch_size <= 0
+ ):
+ raise ValueError(
+ "Wan I2V expected_batch_size must be a positive integer or None, "
+ f"received {expected_batch_size!r}"
+ )
+
+ rows: list[tuple[Image.Image, ...]]
+ if is_multi_image_batch(images):
+ rows = []
+ if isinstance(images, (torch.Tensor, np.ndarray)):
+ iterable = list(images)
+ else:
+ iterable = images
+ for row_index, row in enumerate(iterable):
+ if isinstance(row, list) and not row:
+ raise ValueError(f"Wan I2V sample {row_index} requires at least one input image")
+ standardized = standardize_image_batch(row, output_type="pil")
+ rows.append(tuple(standardized))
+ elif is_image(images):
+ rows = [(standardize_image_batch(images, output_type="pil")[0],)]
+ elif is_image_batch(images):
+ standardized = tuple(standardize_image_batch(images, output_type="pil"))
+ if expected_batch_size == 1 and len(standardized) <= 2:
+ rows = [standardized]
+ else:
+ rows = [(image,) for image in standardized]
+ else:
+ raise TypeError(
+ "Wan I2V images must be one image, an image batch, or a multi-image batch, "
+ f"received {type(images).__name__}"
+ )
+
+ if expected_batch_size is not None and len(rows) != expected_batch_size:
+ raise ValueError(
+ "Wan I2V condition-image batch size mismatch: "
+ f"expected {expected_batch_size}, received {len(rows)}"
+ )
+ for row_index, row in enumerate(rows):
+ if len(row) not in (1, 2):
+ raise ValueError(
+ "Wan I2V requires one first-frame image and at most one optional "
+ f"last-frame image per sample, received {len(row)} at sample {row_index}"
+ )
+ return tuple(rows)
+
+
+def append_wan_i2v_last_images(
+ rows: WanConditionImageRows,
+ last_images: Any,
+) -> WanConditionImageRows:
+ """Bind a legacy separate last-image argument to normalized first-frame rows."""
+ if last_images is None:
+ return rows
+ if any(len(row) != 1 for row in rows):
+ raise ValueError(
+ "Wan I2V last_image cannot be combined with image rows that already contain "
+ "an optional last frame"
+ )
+ normalized_last = normalize_wan_i2v_image_rows(
+ last_images,
+ expected_batch_size=len(rows),
+ )
+ if any(len(row) != 1 for row in normalized_last):
+ raise ValueError("Wan I2V last_image must provide exactly one image per sample")
+ return tuple((row[0], last_row[0]) for row, last_row in zip(rows, normalized_last))
+
+
+def preprocess_wan_i2v_image_rows(
+ adapter: Any,
+ rows: WanConditionImageRows,
+ *,
+ height: int,
+ width: int,
+) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
+ """Apply the official Wan video processor to ordered first/last images."""
+ first_images = [row[0] for row in rows]
+ first = adapter.pipeline.video_processor.preprocess(
+ first_images,
+ height=height,
+ width=width,
+ )
+ if not isinstance(first, torch.Tensor):
+ raise TypeError(
+ "Wan video_processor.preprocess must return torch.Tensor, "
+ f"received {type(first).__name__}"
+ )
+ first = first.to(device=adapter.device, dtype=torch.float32)
+
+ has_last = [len(row) == 2 for row in rows]
+ if any(has_last) and not all(has_last):
+ raise ValueError("Wan I2V batches cannot mix first-only and first/last conditions")
+ if not any(has_last):
+ return first, None
+
+ last = adapter.pipeline.video_processor.preprocess(
+ [row[1] for row in rows],
+ height=height,
+ width=width,
+ )
+ if not isinstance(last, torch.Tensor):
+ raise TypeError(
+ "Wan video_processor.preprocess must return torch.Tensor, "
+ f"received {type(last).__name__}"
+ )
+ return first, last.to(device=adapter.device, dtype=torch.float32)
+
+
+def restore_wan_i2v_condition_pixels(
+ condition_images: Any,
+ *,
+ batch_size: int,
+ height: int,
+ width: int,
+ device: torch.device,
+) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
+ """Restore Arrow-cached per-sample first/last pixel tensors for VAE encoding."""
+ rows: list[torch.Tensor]
+ if isinstance(condition_images, torch.Tensor):
+ if condition_images.ndim == 5:
+ if condition_images.shape[0] != batch_size:
+ raise ValueError(
+ "Wan cached condition_images batch mismatch: "
+ f"expected {batch_size}, received {condition_images.shape[0]}"
+ )
+ rows = list(condition_images.unbind(0))
+ elif condition_images.ndim == 4 and batch_size == 1:
+ rows = [condition_images]
+ elif condition_images.ndim == 4 and condition_images.shape[0] == batch_size:
+ rows = [value.unsqueeze(0) for value in condition_images.unbind(0)]
+ else:
+ raise ValueError(
+ "Wan cached condition_images tensor must be BNCHW or one sample's NCHW, "
+ f"received shape {tuple(condition_images.shape)}"
+ )
+ elif isinstance(condition_images, Sequence) and not isinstance(condition_images, (str, bytes)):
+ if len(condition_images) != batch_size:
+ raise ValueError(
+ "Wan cached condition_images sequence batch mismatch: "
+ f"expected {batch_size}, received {len(condition_images)}"
+ )
+ rows = []
+ for index, value in enumerate(condition_images):
+ if not isinstance(value, torch.Tensor):
+ raise TypeError(
+ "Wan cached condition_images entries must be tensors, "
+ f"received {type(value).__name__} at sample {index}"
+ )
+ rows.append(value.unsqueeze(0) if value.ndim == 3 else value)
+ else:
+ raise TypeError(
+ "Wan cached condition_images must be a tensor or per-sample tensor sequence, "
+ f"received {type(condition_images).__name__}"
+ )
+
+ expected_tail = (3, height, width)
+ image_counts = []
+ for index, row in enumerate(rows):
+ if row.ndim != 4 or row.shape[0] not in (1, 2) or tuple(row.shape[1:]) != expected_tail:
+ raise ValueError(
+ "Wan cached condition_images must contain one first and optional last "
+ f"pixel tensor shaped {expected_tail}; received {tuple(row.shape)} "
+ f"at sample {index}"
+ )
+ image_counts.append(row.shape[0])
+ if any(count != image_counts[0] for count in image_counts):
+ raise ValueError("Wan I2V batches cannot mix first-only and first/last conditions")
+
+ first = torch.stack([row[0] for row in rows], dim=0).to(
+ device=device,
+ dtype=torch.float32,
+ )
+ if image_counts[0] == 1:
+ return first, None
+ last = torch.stack([row[1] for row in rows], dim=0).to(
+ device=device,
+ dtype=torch.float32,
+ )
+ return first, last
+
+
+def prepare_wan_i2v_condition_tensors(
+ adapter: Any,
+ image: torch.Tensor,
+ *,
+ height: int,
+ width: int,
+ num_frames: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ last_image: Optional[torch.Tensor] = None,
+) -> WanI2VConditionTensors:
+ """Encode ordered input frames with posterior mode and build Wan condition channels."""
+ if not isinstance(dtype, torch.dtype) or not dtype.is_floating_point:
+ raise TypeError(f"Wan I2V condition dtype must be floating, received {dtype!r}")
+ if not isinstance(image, torch.Tensor) or image.ndim != 4:
+ raise ValueError(
+ "Wan I2V first-frame pixels must be rank-4 BCHW, "
+ f"received {type(image).__name__} with shape {getattr(image, 'shape', None)}"
+ )
+ batch_size = image.shape[0]
+ expected_pixels = (batch_size, 3, height, width)
+ if tuple(image.shape) != expected_pixels:
+ raise ValueError(
+ f"Wan I2V first-frame pixels must have shape {expected_pixels}, "
+ f"received {tuple(image.shape)}"
+ )
+ if last_image is not None:
+ if adapter.pipeline.config.expand_timesteps:
+ raise ValueError(
+ "Wan I2V expand_timesteps does not support an optional last-frame image; "
+ "Diffusers would otherwise ignore it"
+ )
+ if num_frames < 2:
+ raise ValueError(
+ "Wan I2V optional last-frame conditioning requires num_frames >= 2, "
+ f"received {num_frames}"
+ )
+ if not isinstance(last_image, torch.Tensor) or tuple(last_image.shape) != expected_pixels:
+ raise ValueError(
+ f"Wan I2V last-frame pixels must have shape {expected_pixels}, "
+ f"received {type(last_image).__name__} with shape "
+ f"{getattr(last_image, 'shape', None)}"
+ )
+
+ temporal_scale = adapter.pipeline.vae_scale_factor_temporal
+ spatial_scale = adapter.pipeline.vae_scale_factor_spatial
+ if (num_frames - 1) % temporal_scale:
+ raise ValueError(
+ "Wan I2V num_frames must satisfy "
+ f"(num_frames - 1) % {temporal_scale} == 0, received {num_frames}"
+ )
+ num_latent_frames = (num_frames - 1) // temporal_scale + 1
+ latent_height = height // spatial_scale
+ latent_width = width // spatial_scale
+
+ image = image.unsqueeze(2)
+ if adapter.pipeline.config.expand_timesteps:
+ video_condition = image
+ elif last_image is None:
+ video_condition = torch.cat(
+ [
+ image,
+ image.new_zeros(batch_size, image.shape[1], num_frames - 1, height, width),
+ ],
+ dim=2,
+ )
+ else:
+ video_condition = torch.cat(
+ [
+ image,
+ image.new_zeros(batch_size, image.shape[1], num_frames - 2, height, width),
+ last_image.unsqueeze(2),
+ ],
+ dim=2,
+ )
+
+ vae = adapter.vae
+ vae_dtype = getattr(vae, "dtype", None)
+ if not isinstance(vae_dtype, torch.dtype) or not vae_dtype.is_floating_point:
+ raise TypeError(
+ "Wan I2V condition encoder expected VAE to expose a floating dtype, "
+ f"received {vae_dtype!r}"
+ )
+ encoded = vae.encode(video_condition.to(device=device, dtype=vae_dtype))
+ latent_condition = retrieve_vae_latents(
+ encoded,
+ sample_mode="argmax",
+ source="Wan input video condition",
+ ).to(device=device, dtype=dtype)
+ latent_condition = normalize_wan_video_latents(adapter, latent_condition)
+
+ expected_latents = (
+ batch_size,
+ vae.config.z_dim,
+ num_latent_frames,
+ latent_height,
+ latent_width,
+ )
+ if tuple(latent_condition.shape) != expected_latents:
+ raise ValueError(
+ "Wan I2V condition latent geometry mismatch: "
+ f"expected {expected_latents}, received {tuple(latent_condition.shape)}"
+ )
+
+ if adapter.pipeline.config.expand_timesteps:
+ first_frame_mask = torch.ones(
+ batch_size,
+ 1,
+ num_latent_frames,
+ latent_height,
+ latent_width,
+ dtype=dtype,
+ device=device,
+ )
+ first_frame_mask[:, :, 0] = 0
+ return WanI2VConditionTensors(latent_condition, first_frame_mask)
+
+ mask = torch.ones(
+ batch_size,
+ 1,
+ num_frames,
+ latent_height,
+ latent_width,
+ dtype=dtype,
+ device=device,
+ )
+ if last_image is None:
+ mask[:, :, 1:] = 0
+ else:
+ mask[:, :, 1:-1] = 0
+ first = torch.repeat_interleave(mask[:, :, :1], dim=2, repeats=temporal_scale)
+ mask = torch.cat([first, mask[:, :, 1:]], dim=2)
+ mask = mask.view(
+ batch_size,
+ -1,
+ temporal_scale,
+ latent_height,
+ latent_width,
+ ).transpose(1, 2)
+ condition = torch.cat([mask, latent_condition], dim=1)
+ return WanI2VConditionTensors(condition, None)
+
+
+def normalize_wan_image_embeds(image_embeds: Any, *, batch_size: int) -> torch.Tensor:
+ """Pack cached per-sample CLIP embeddings into Diffusers' forward layout."""
+ if isinstance(image_embeds, torch.Tensor):
+ if image_embeds.ndim == 2:
+ if batch_size != 1:
+ raise ValueError(
+ "unbatched Wan image_embeds require batch_size=1, " f"received {batch_size}"
+ )
+ return image_embeds.unsqueeze(0)
+ if image_embeds.ndim == 3:
+ return image_embeds
+ if image_embeds.ndim == 4:
+ if image_embeds.shape[0] != batch_size:
+ raise ValueError(
+ "Wan image_embeds batch mismatch: "
+ f"expected {batch_size}, received {image_embeds.shape[0]}"
+ )
+ return image_embeds.flatten(0, 1)
+ raise ValueError(
+ "Wan image_embeds must be rank 2, 3, or 4, "
+ f"received shape {tuple(image_embeds.shape)}"
+ )
+ if isinstance(image_embeds, Sequence) and not isinstance(image_embeds, (str, bytes)):
+ if len(image_embeds) != batch_size:
+ raise ValueError(
+ "Wan image_embeds sequence batch mismatch: "
+ f"expected {batch_size}, received {len(image_embeds)}"
+ )
+ packed = []
+ for index, value in enumerate(image_embeds):
+ if not isinstance(value, torch.Tensor) or value.ndim not in (2, 3):
+ raise ValueError(
+ "Wan image_embeds sequence entries must be rank-2 or rank-3 tensors, "
+ f"received {type(value).__name__} with shape "
+ f"{getattr(value, 'shape', None)} at sample {index}"
+ )
+ packed.append(value.unsqueeze(0) if value.ndim == 2 else value)
+ return torch.cat(packed, dim=0)
+ raise TypeError(
+ "Wan image_embeds must be a tensor or per-sample tensor sequence, "
+ f"received {type(image_embeds).__name__}"
+ )
+
+
+def split_wan_image_embeds(
+ image_embeds: torch.Tensor,
+ image_counts: Sequence[int],
+) -> Tuple[torch.Tensor, ...]:
+ """Split Diffusers' packed CLIP layout back into per-sample replay tensors."""
+ if not isinstance(image_embeds, torch.Tensor) or image_embeds.ndim != 3:
+ raise ValueError(
+ "packed Wan image_embeds must be rank-3, "
+ f"received {type(image_embeds).__name__} with shape "
+ f"{getattr(image_embeds, 'shape', None)}"
+ )
+ counts = tuple(image_counts)
+ if not counts or any(type(count) is not int or count not in (1, 2) for count in counts):
+ raise ValueError(
+ "Wan image_counts must contain one or two images per sample, " f"received {counts!r}"
+ )
+ if sum(counts) != image_embeds.shape[0]:
+ raise ValueError(
+ "packed Wan image_embeds count mismatch: "
+ f"expected {sum(counts)}, received {image_embeds.shape[0]}"
+ )
+ per_sample = []
+ offset = 0
+ for count in counts:
+ value = image_embeds[offset : offset + count]
+ per_sample.append(value[0] if count == 1 else value)
+ offset += count
+ return tuple(per_sample)
+
+
+@dataclass(frozen=True, slots=True)
+class WanI2VConditionStatePreparer:
+ """Realize input-owned Wan VAE conditions once per offline batch."""
+
+ adapter: Any
+ required_components: ClassVar[Tuple[str, ...]] = ("vae",)
+
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> PreparedConditionState:
+ """Bind cached images to configured geometry with deterministic VAE mode."""
+ del generator
+ if "condition_images" not in condition:
+ raise ValueError(
+ "Wan I2V cached condition is missing preprocessed 'condition_images'; "
+ "rebuild the input-condition cache with this adapter version"
+ )
+ height, width, num_frames, _ = configured_wan_video_output_geometry(self.adapter)
+ first, last = restore_wan_i2v_condition_pixels(
+ condition["condition_images"],
+ batch_size=1,
+ height=height,
+ width=width,
+ device=self.adapter.device,
+ )
+ realized = prepare_wan_i2v_condition_tensors(
+ self.adapter,
+ first,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ dtype=torch.float32,
+ device=self.adapter.device,
+ last_image=last,
+ )
+
+ cached_condition = dict(condition)
+ cached_condition.pop("condition_images")
+ cached_condition.pop("images", None)
+ forward_context: dict[str, Any] = {"latent_condition": realized.condition}
+ image_embeds = cached_condition.pop("image_embeds", None)
+ if image_embeds is not None:
+ packed_image_embeds = normalize_wan_image_embeds(
+ image_embeds,
+ batch_size=first.shape[0],
+ )
+ expected_image_embeds = first.shape[0] * (2 if last is not None else 1)
+ if packed_image_embeds.shape[0] != expected_image_embeds:
+ raise ValueError(
+ "Wan cached image_embeds count disagrees with condition_images: "
+ f"expected {expected_image_embeds}, received "
+ f"{packed_image_embeds.shape[0]}"
+ )
+ forward_context["image_embeds"] = packed_image_embeds
+
+ output_context: dict[str, Any] = {}
+ if realized.first_frame_mask is not None:
+ forward_context["first_frame_mask"] = realized.first_frame_mask
+ output_context["first_frame_mask"] = realized.first_frame_mask
+ return PreparedConditionState(
+ condition=cached_condition,
+ forward_context=forward_context,
+ output_context=output_context,
+ )
+
+
+__all__ = [
+ "WanConditionImageRows",
+ "WanI2VConditionStatePreparer",
+ "WanI2VConditionTensors",
+ "append_wan_i2v_last_images",
+ "normalize_wan_i2v_image_rows",
+ "normalize_wan_image_embeds",
+ "prepare_wan_i2v_condition_tensors",
+ "preprocess_wan_i2v_image_rows",
+ "restore_wan_i2v_condition_pixels",
+ "split_wan_image_embeds",
+]
diff --git a/src/flow_factory/models/wan/_output.py b/src/flow_factory/models/wan/_output.py
index 887e61232..430c0de78 100644
--- a/src/flow_factory/models/wan/_output.py
+++ b/src/flow_factory/models/wan/_output.py
@@ -12,12 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""On-the-fly clean-video encoding for Wan text-to-video adapters."""
+"""Shared on-the-fly clean-video encoding for Wan video adapters."""
from __future__ import annotations
+import math
from collections.abc import Mapping
from dataclasses import dataclass
+from numbers import Real
from typing import Any, ClassVar, Optional, Tuple
import numpy as np
@@ -34,11 +36,172 @@
)
+def configured_wan_video_output_geometry(adapter: Any) -> Tuple[int, int, int, float]:
+ """Return configured Wan geometry after exact latent-grid validation."""
+ geometry = []
+ for name in ("height", "width", "num_frames"):
+ value = getattr(adapter.training_args, name, None)
+ if type(value) is not int or value <= 0:
+ raise ValueError(
+ f"Wan output geometry requires positive integer train.{name}, "
+ f"received {value!r}"
+ )
+ geometry.append(value)
+ frame_rate = getattr(adapter.training_args, "frame_rate", None)
+ if isinstance(frame_rate, bool) or not isinstance(frame_rate, Real):
+ raise TypeError(
+ "Wan output geometry requires finite positive train.frame_rate, "
+ f"received {type(frame_rate).__name__}: {frame_rate!r}"
+ )
+ frame_rate = float(frame_rate)
+ if not math.isfinite(frame_rate) or frame_rate <= 0:
+ raise ValueError(
+ "Wan output geometry requires finite positive train.frame_rate, "
+ f"received {frame_rate!r}"
+ )
+
+ height, width, num_frames = geometry
+ temporal_scale = adapter.pipeline.vae_scale_factor_temporal
+ spatial_scale = adapter.pipeline.vae_scale_factor_spatial
+ if (num_frames - 1) % temporal_scale:
+ raise ValueError(
+ "Wan output num_frames must satisfy "
+ f"(num_frames - 1) % {temporal_scale} == 0, received {num_frames}"
+ )
+ transformer = (
+ adapter.pipeline.transformer
+ if adapter.pipeline.transformer is not None
+ else adapter.pipeline.transformer_2
+ )
+ if transformer is None:
+ raise RuntimeError("Wan output geometry requires one materialized transformer")
+ patch_size = transformer.config.patch_size
+ height_multiple = spatial_scale * patch_size[1]
+ width_multiple = spatial_scale * patch_size[2]
+ if height % height_multiple or width % width_multiple:
+ raise ValueError(
+ "Wan output height/width must be divisible by transformer latent-grid "
+ f"multiples {(height_multiple, width_multiple)}, received {(height, width)}"
+ )
+ return height, width, num_frames, frame_rate
+
+
+def resample_wan_output_video(
+ video: np.ndarray,
+ *,
+ source_fps: Optional[float],
+ target_frames: int,
+ target_fps: float,
+) -> np.ndarray:
+ """Select deterministic nearest-time frames for configured target cadence."""
+ if video.dtype != np.uint8 or video.ndim != 4 or video.shape[-1] != 3:
+ raise ValueError(
+ "Wan decoded target video must be uint8 RGB shaped (F,H,W,3), "
+ f"received dtype={video.dtype}, shape={tuple(video.shape)}"
+ )
+ if video.shape[0] < 1:
+ raise ValueError("Wan decoded target video must contain at least one frame")
+ if isinstance(source_fps, bool) or not isinstance(source_fps, Real):
+ raise TypeError(
+ "Wan target video requires source fps metadata, "
+ f"received {type(source_fps).__name__}: {source_fps!r}"
+ )
+ source_fps = float(source_fps)
+ if not math.isfinite(source_fps) or source_fps <= 0:
+ raise ValueError(f"Wan target video requires positive finite fps, got {source_fps!r}")
+ indices = np.rint(np.arange(target_frames, dtype=np.float64) * source_fps / target_fps).astype(
+ np.int64
+ )
+ if indices[-1] >= video.shape[0]:
+ required_duration = (target_frames - 1) / target_fps
+ available_duration = (video.shape[0] - 1) / source_fps
+ raise ValueError(
+ "Wan target video is too short for configured temporal geometry: "
+ f"requires {required_duration:.6f}s, has {available_duration:.6f}s"
+ )
+ return np.ascontiguousarray(video[indices])
+
+
+def normalize_wan_video_latents(adapter: Any, latents: torch.Tensor) -> torch.Tensor:
+ """Apply the exact inverse of Wan's existing decode normalization."""
+ if not isinstance(latents, torch.Tensor) or latents.ndim != 5:
+ raise ValueError(
+ "Wan VAE video latents must be rank-5 BCFHW, "
+ f"received {type(latents).__name__} with shape "
+ f"{getattr(latents, 'shape', None)}"
+ )
+ config = adapter.vae.config
+ z_dim = config.z_dim
+ if latents.shape[1] != z_dim:
+ raise ValueError(
+ f"Wan VAE video latent channels must equal z_dim={z_dim}, "
+ f"received {latents.shape[1]}"
+ )
+ latents_mean = torch.as_tensor(
+ config.latents_mean,
+ device=latents.device,
+ dtype=latents.dtype,
+ ).view(1, z_dim, 1, 1, 1)
+ inverse_std = (
+ torch.as_tensor(
+ config.latents_std,
+ device=latents.device,
+ dtype=latents.dtype,
+ )
+ .reciprocal()
+ .view(1, z_dim, 1, 1, 1)
+ )
+ return (latents - latents_mean) * inverse_std
+
+
+def validate_wan_encoded_output_geometry(
+ adapter: Any,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+) -> None:
+ """Require encoded signatures and decode metadata to match train geometry."""
+ del condition
+ height, width, num_frames, frame_rate = configured_wan_video_output_geometry(adapter)
+ if len(encoded.geometry_signatures) != len(media_batch):
+ raise ValueError(
+ "Wan output codec must return one geometry signature per sample, "
+ f"received {len(encoded.geometry_signatures)} for {len(media_batch)}"
+ )
+ for sample_index, signature in enumerate(encoded.geometry_signatures):
+ geometry = signature.media[0]
+ received = (
+ geometry.height,
+ geometry.width,
+ geometry.frames,
+ geometry.fps,
+ )
+ expected = (height, width, num_frames, frame_rate)
+ if received != expected:
+ raise ValueError(
+ "Wan encoded output geometry disagrees with configured geometry for "
+ f"sample {sample_index}: expected {expected}, received {received}"
+ )
+ expected_context = {
+ "height": height,
+ "width": width,
+ "num_frames": num_frames,
+ "frame_rate": frame_rate,
+ }
+ for name, expected in expected_context.items():
+ if encoded.decode_context.get(name) != expected:
+ raise ValueError(
+ f"Wan decode_context {name!r} must equal {expected!r}, "
+ f"received {encoded.decode_context.get(name)!r}"
+ )
+
+
@dataclass(frozen=True, slots=True)
class WanVideoOutputCodec:
"""Encode configured Wan target videos without retaining pixels or latents."""
adapter: Any
+ bind_condition_active_mask: bool = False
required_components: ClassVar[Tuple[str, ...]] = ("vae",)
def encode_output_state(
@@ -48,8 +211,28 @@ def encode_output_state(
generator: Optional[torch.Generator] = None,
) -> EncodedOutputState:
"""Preprocess, VAE-sample, and normalize one video per sample."""
- del condition
- height, width, num_frames, frame_rate = self.adapter._configured_video_output_geometry()
+ height, width, num_frames, frame_rate = configured_wan_video_output_geometry(self.adapter)
+ first_frame_mask = None
+ if self.bind_condition_active_mask:
+ first_frame_mask = condition.get("first_frame_mask")
+ expand_timesteps = bool(self.adapter.pipeline.config.expand_timesteps)
+ if expand_timesteps and first_frame_mask is None:
+ raise ValueError(
+ "Wan I2V expand_timesteps target encoding requires first_frame_mask "
+ "from the prepared input condition"
+ )
+ if not expand_timesteps and first_frame_mask is not None:
+ raise ValueError(
+ "Wan I2V non-expanded target encoding must not receive first_frame_mask"
+ )
+ if first_frame_mask is not None:
+ if not isinstance(first_frame_mask, torch.Tensor):
+ raise TypeError(
+ "Wan I2V first_frame_mask must be torch.Tensor, "
+ f"received {type(first_frame_mask).__name__}"
+ )
+ if not torch.all((first_frame_mask == 0) | (first_frame_mask == 1)):
+ raise ValueError("Wan I2V first_frame_mask must contain only zero and one")
videos = []
for sample_index, candidate in enumerate(media_batch):
if len(candidate) != 1:
@@ -65,7 +248,7 @@ def encode_output_state(
f"received {type(payload).__name__} for sample {sample_index}"
)
videos.append(
- self.adapter._resample_output_video(
+ resample_wan_output_video(
payload,
source_fps=media.fps,
target_frames=num_frames,
@@ -105,7 +288,7 @@ def encode_output_state(
generator=generator,
source="Wan target video",
)
- latents = self.adapter._normalize_output_video_latents(latents)
+ latents = normalize_wan_video_latents(self.adapter, latents)
temporal_scale = self.adapter.pipeline.vae_scale_factor_temporal
spatial_scale = self.adapter.pipeline.vae_scale_factor_spatial
@@ -133,8 +316,13 @@ def encode_output_state(
),
)
)
+ active_masks = None
+ if first_frame_mask is not None:
+ active_mask = first_frame_mask.to(device=latents.device, dtype=torch.bool)
+ active_masks = {"latent": active_mask}
+
return EncodedOutputState(
- clean_state=LatentState({"latent": latents}),
+ clean_state=LatentState({"latent": latents}, active_masks=active_masks),
forward_context={},
decode_context={
"height": height,
@@ -146,4 +334,10 @@ def encode_output_state(
)
-__all__ = ["WanVideoOutputCodec"]
+__all__ = [
+ "WanVideoOutputCodec",
+ "configured_wan_video_output_geometry",
+ "normalize_wan_video_latents",
+ "resample_wan_output_video",
+ "validate_wan_encoded_output_geometry",
+]
diff --git a/src/flow_factory/models/wan/wan2_i2v.py b/src/flow_factory/models/wan/wan2_i2v.py
index b437e79b0..297d97c59 100644
--- a/src/flow_factory/models/wan/wan2_i2v.py
+++ b/src/flow_factory/models/wan/wan2_i2v.py
@@ -15,11 +15,9 @@
# src/flow_factory/models/wan/wan2_i2v.py
from __future__ import annotations
-import logging
-import os
-from collections import defaultdict
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, Iterable, List, Literal, Optional, Tuple, Union
+from types import MappingProxyType
+from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
import numpy as np
import torch
@@ -27,30 +25,49 @@
from diffusers.pipelines.wan.pipeline_wan_i2v import WanImageToVideoPipeline, prompt_clean
from diffusers.utils.torch_utils import randn_tensor
from peft import PeftModel
-from PIL import Image
+from ...contracts import (
+ BatchCapability,
+ GeometrySource,
+ NegativePromptPolicy,
+ PipelineIOContract,
+ RateRequirement,
+)
from ...hparams import *
from ...samples import I2VSample
from ...scheduler import UniPCMultistepSDEScheduler, UniPCMultistepSDESchedulerOutput
-from ...utils.base import filter_kwargs
from ...utils.image import (
ImageBatch,
ImageSingle,
MultiImageBatch,
- is_image,
- is_image_batch,
- is_multi_image_batch,
- standardize_image_batch,
)
from ...utils.logger_utils import setup_logger
from ...utils.trajectory_collector import (
- CallbackCollector,
- TrajectoryCollector,
TrajectoryIndicesType,
create_callback_collector,
create_trajectory_collector,
)
from ..abc import BaseAdapter
+from ..condition_state import ConditionStatePreparer
+from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
+from ..pipeline_contracts import video_output_contract
+from ._conditioning import (
+ WanI2VConditionStatePreparer,
+ append_wan_i2v_last_images,
+ normalize_wan_i2v_image_rows,
+ normalize_wan_image_embeds,
+ prepare_wan_i2v_condition_tensors,
+ preprocess_wan_i2v_image_rows,
+ restore_wan_i2v_condition_pixels,
+ split_wan_image_embeds,
+)
+from ._output import (
+ WanVideoOutputCodec,
+ configured_wan_video_output_geometry,
+ normalize_wan_video_latents,
+ resample_wan_output_video,
+ validate_wan_encoded_output_geometry,
+)
logger = setup_logger(__name__)
@@ -58,33 +75,18 @@
@dataclass
class WanI2VSample(I2VSample):
# Class var
- _shared_fields: ClassVar[frozenset[str]] = frozenset({"first_frame_mask"})
+ _shared_fields: ClassVar[frozenset[str]] = frozenset()
# Obj var
image_embeds: Optional[torch.FloatTensor] = None
- condition: Optional[torch.FloatTensor] = None
+ latent_condition: Optional[torch.FloatTensor] = None
first_frame_mask: Optional[torch.FloatTensor] = None
-def retrieve_latents(
- encoder_output: torch.Tensor,
- generator: Optional[torch.Generator] = None,
- sample_mode: str = "sample",
-):
- if hasattr(encoder_output, "latent_dist") and sample_mode == "sample":
- return encoder_output.latent_dist.sample(generator)
- elif hasattr(encoder_output, "latent_dist") and sample_mode == "argmax":
- return encoder_output.latent_dist.mode()
- elif hasattr(encoder_output, "latents"):
- return encoder_output.latents
- else:
- raise AttributeError("Could not access latents of provided encoder_output")
-
-
class Wan2_I2V_Adapter(BaseAdapter):
- output_state_codec_unavailable_reason = (
- "Wan I2V target encoding must bind an output-geometry-dependent first-frame VAE "
- "condition (and Wan 2.2 first-frame mask); the current condition cache retains only "
- "CLIP image embeddings, so this target/condition binder is not yet implemented"
+ preprocess_cache_fields = frozenset({"height", "width"})
+ preprocess_cache_version = "wan-i2v-condition-pixels-v1"
+ offline_training_forward_overrides = MappingProxyType(
+ {"guidance_scale": 1.0, "guidance_scale_2": 1.0}
)
# Wan2.2 trains both transformer and transformer_2 but uses only one per
# timestep (boundary_ratio), so under DDP the other's trainable params get no
@@ -97,12 +99,21 @@ class Wan2_I2V_Adapter(BaseAdapter):
"vae": torch.float32,
"image_encoder": torch.float32,
}
+ pipeline_io_contract = video_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ input_image_min_count=1,
+ input_image_max_count=2,
+ input_image_slots=("first_frame", "last_frame"),
+ required_input_image_slots=("first_frame",),
+ output_fps=RateRequirement.REQUIRED,
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
def __init__(self, config: Arguments, accelerator: Accelerator):
super().__init__(config, accelerator)
self.pipeline: WanImageToVideoPipeline
self.scheduler: UniPCMultistepSDEScheduler
- self._has_warned_multi_image = False
def load_pipeline(self) -> WanImageToVideoPipeline:
return self._load_diffusers_pipeline(
@@ -110,6 +121,21 @@ def load_pipeline(self) -> WanImageToVideoPipeline:
self.model_args.model_name_or_path,
)
+ def _resolve_pipeline_io_contract(self) -> PipelineIOContract:
+ """Narrow expand-timestep checkpoints to their first-frame-only semantics."""
+ if not self.pipeline.config.expand_timesteps:
+ return type(self).pipeline_io_contract
+ return video_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ input_image_min_count=1,
+ input_image_max_count=1,
+ input_image_slots=("first_frame",),
+ required_input_image_slots=("first_frame",),
+ output_fps=RateRequirement.REQUIRED,
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
+
@property
def default_target_modules(self) -> List[str]:
"""Default LoRA target modules for Wan transformer."""
@@ -143,7 +169,7 @@ def inference_modules(self) -> List[str]:
@property
def preprocessing_modules(self) -> List[str]:
"""Modules that are requires for preprocessing"""
- return ["text_encoders", "vae", "image_encoder"]
+ return ["text_encoders", "image_encoder"]
def apply_lora(
self,
@@ -298,59 +324,112 @@ def encode_image(
self,
images: Union[ImageSingle, ImageBatch, MultiImageBatch],
device: Optional[torch.device] = None,
- ) -> Union[None, Dict[str, torch.Tensor]]:
- images = self._standardize_image_input(
- images,
- output_type="pil",
- )
-
- if not is_image_batch(images):
+ height: Optional[int] = None,
+ width: Optional[int] = None,
+ ) -> Dict[str, Union[List[torch.Tensor], torch.Tensor]]:
+ rows = normalize_wan_i2v_image_rows(images)
+ height = height if height is not None else getattr(self.training_args, "height", None)
+ width = width if width is not None else getattr(self.training_args, "width", None)
+ if type(height) is not int or height <= 0 or type(width) is not int or width <= 0:
raise ValueError(
- f"Invalid image input type: {type(images)}. "
- f"Must be a PIL Image, numpy array, torch tensor, or a list of these types."
+ "Wan I2V image preprocessing requires positive integer train.height/width, "
+ f"received {(height, width)}"
)
+ condition_images = []
+ for row_index, row in enumerate(rows):
+ pixel_values = self.pipeline.video_processor.preprocess(
+ list(row),
+ height=height,
+ width=width,
+ )
+ expected_shape = (len(row), 3, height, width)
+ if (
+ not isinstance(pixel_values, torch.Tensor)
+ or tuple(pixel_values.shape) != expected_shape
+ ):
+ raise ValueError(
+ "Wan video_processor.preprocess changed condition-image geometry at "
+ f"sample {row_index}: expected {expected_shape}, received "
+ f"{getattr(pixel_values, 'shape', None)}"
+ )
+ condition_images.append(pixel_values.detach().to(device="cpu", dtype=torch.float32))
+ results: Dict[str, Union[List[torch.Tensor], torch.Tensor]] = {
+ "condition_images": condition_images
+ }
# only Wan 2.1 I2V transformer accepts image_embeds, else None directly
if (
self.pipeline.transformer is not None
and self.pipeline.transformer.config.image_dim is not None
):
- batch_size = len(images)
device = device or self.image_encoder.device
- images = self.pipeline.image_processor(images=images, return_tensors="pt").to(device)
- image_embeds = self.pipeline.image_encoder(**images, output_hidden_states=True)
- return {
- "image_embeds": image_embeds.hidden_states[-2],
- }
- else:
- return None
-
- def _standardize_image_input(
- self,
- images: Union[ImageSingle, ImageBatch, MultiImageBatch],
- output_type: Literal["pil", "pt", "np"] = "pil",
- ):
- """
- Standardize image input to desired output type.
- """
- if isinstance(images, Image.Image):
- images = [images]
- elif is_multi_image_batch(images):
- # A list of list of images
- if any(len(batch) > 1 for batch in images) and not self._has_warned_multi_image:
- self._has_warned_multi_image = True
- logger.warning(
- "Multiple condition images are not supported for Wan2_I2V. Only the first image of each batch will be used."
+ counts = [len(row) for row in rows]
+ flattened = [image for row in rows for image in row]
+ processor_output = self.pipeline.image_processor(
+ images=flattened,
+ return_tensors="pt",
+ ).to(device)
+ image_embeds = self.pipeline.image_encoder(
+ **processor_output,
+ output_hidden_states=True,
+ ).hidden_states[-2]
+ if not isinstance(image_embeds, torch.Tensor) or image_embeds.shape[0] != sum(counts):
+ raise ValueError(
+ "Wan image encoder must preserve the flattened condition-image count, "
+ f"expected {sum(counts)}, received "
+ f"{getattr(image_embeds, 'shape', None)}"
)
+ if all(count == 1 for count in counts):
+ results["image_embeds"] = image_embeds
+ return results
+ per_sample = []
+ offset = 0
+ for count in counts:
+ per_sample.append(image_embeds[offset : offset + count])
+ offset += count
+ results["image_embeds"] = per_sample
+ return results
- images = [batch[0] for batch in images]
+ def build_condition_state_preparer(self) -> ConditionStatePreparer:
+ """Declare on-the-fly input-frame VAE conditioning."""
+ return WanI2VConditionStatePreparer(self)
+
+ def build_output_state_codec(self) -> OutputStateCodec:
+ """Declare the shared Wan target-video codec with I2V active-mask binding."""
+ return WanVideoOutputCodec(self, bind_condition_active_mask=True)
+
+ def _configured_video_output_geometry(self) -> Tuple[int, int, int, float]:
+ """Return configured Wan geometry after exact latent-grid validation."""
+ return configured_wan_video_output_geometry(self)
+
+ @staticmethod
+ def _resample_output_video(
+ video: np.ndarray,
+ *,
+ source_fps: Optional[float],
+ target_frames: int,
+ target_fps: float,
+ ) -> np.ndarray:
+ """Select deterministic nearest-time frames for configured target cadence."""
+ return resample_wan_output_video(
+ video,
+ source_fps=source_fps,
+ target_frames=target_frames,
+ target_fps=target_fps,
+ )
- images = standardize_image_batch(images, output_type=output_type)
- return images
+ def _normalize_output_video_latents(self, latents: torch.Tensor) -> torch.Tensor:
+ """Apply Wan's VAE latent normalization."""
+ return normalize_wan_video_latents(self, latents)
- # ------------------------ Video Encoding ------------------------
- def encode_video(self, videos: Union[np.ndarray, torch.Tensor, List[Image.Image]]):
- pass
+ def _validate_encoded_output_geometry(
+ self,
+ media_batch: DecodedMediaBatch,
+ condition: Mapping[str, Any],
+ encoded: EncodedOutputState,
+ ) -> None:
+ """Require encoded target metadata to match configured video geometry."""
+ validate_wan_encoded_output_geometry(self, media_batch, condition, encoded)
# ------------------------ Latent Decoding ------------------------
def decode_latents(
@@ -388,8 +467,10 @@ def prepare_latents(
last_image: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
- Modified from `diffusers: WanImageToVideoPipeline` with batch_size bug fixed
+ Prepare rollout noise and reuse the offline condition realization helper.
"""
+ dtype = dtype or torch.float32
+ device = device or self.device
num_latent_frames = (num_frames - 1) // self.pipeline.vae_scale_factor_temporal + 1
latent_height = height // self.pipeline.vae_scale_factor_spatial
latent_width = width // self.pipeline.vae_scale_factor_spatial
@@ -406,74 +487,19 @@ def prepare_latents(
else:
latents = latents.to(device=device, dtype=dtype)
- image = image.unsqueeze(2) # [batch_size, channels, 1, height, width]
-
- if self.pipeline.config.expand_timesteps:
- video_condition = image
-
- elif last_image is None:
- video_condition = torch.cat(
- [
- image,
- image.new_zeros(image.shape[0], image.shape[1], num_frames - 1, height, width),
- ],
- dim=2,
- )
- else:
- last_image = last_image.unsqueeze(2)
- video_condition = torch.cat(
- [
- image,
- image.new_zeros(image.shape[0], image.shape[1], num_frames - 2, height, width),
- last_image,
- ],
- dim=2,
- )
- video_condition = video_condition.to(device=device, dtype=self.pipeline.vae.dtype)
-
- latents_mean = (
- torch.tensor(self.pipeline.vae.config.latents_mean)
- .view(1, self.pipeline.vae.config.z_dim, 1, 1, 1)
- .to(latents.device, latents.dtype)
- )
- latents_std = 1.0 / torch.tensor(self.pipeline.vae.config.latents_std).view(
- 1, self.pipeline.vae.config.z_dim, 1, 1, 1
- ).to(latents.device, latents.dtype)
-
- latent_condition = retrieve_latents(
- self.pipeline.vae.encode(video_condition), sample_mode="argmax"
- )
- if latent_condition.shape[0] == 1 and batch_size > 1:
- latent_condition = latent_condition.repeat(batch_size, 1, 1, 1, 1)
-
- latent_condition = latent_condition.to(dtype)
- latent_condition = (latent_condition - latents_mean) * latents_std
-
- if self.pipeline.config.expand_timesteps:
- first_frame_mask = torch.ones(
- 1, 1, num_latent_frames, latent_height, latent_width, dtype=dtype, device=device
- )
- first_frame_mask[:, :, 0] = 0
- return latents, latent_condition, first_frame_mask
-
- mask_lat_size = torch.ones(batch_size, 1, num_frames, latent_height, latent_width)
-
- if last_image is None:
- mask_lat_size[:, :, 1:] = 0
- else:
- mask_lat_size[:, :, 1:-1] = 0
- first_frame_mask = mask_lat_size[:, :, 0:1]
- first_frame_mask = torch.repeat_interleave(
- first_frame_mask, dim=2, repeats=self.pipeline.vae_scale_factor_temporal
- )
- mask_lat_size = torch.concat([first_frame_mask, mask_lat_size[:, :, 1:, :]], dim=2)
- mask_lat_size = mask_lat_size.view(
- batch_size, -1, self.pipeline.vae_scale_factor_temporal, latent_height, latent_width
+ realized = prepare_wan_i2v_condition_tensors(
+ self,
+ image,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ dtype=dtype,
+ device=device,
+ last_image=last_image,
)
- mask_lat_size = mask_lat_size.transpose(1, 2)
- mask_lat_size = mask_lat_size.to(latent_condition.device)
-
- return latents, torch.concat([mask_lat_size, latent_condition], dim=1)
+ if realized.first_frame_mask is not None:
+ return latents, realized.condition, realized.first_frame_mask
+ return latents, realized.condition
# ======================== Inference ========================
@torch.no_grad()
@@ -499,7 +525,7 @@ def inference(
# Encoded Image
image_embeds: Optional[torch.Tensor] = None,
condition_images: Optional[Union[torch.Tensor, List[torch.Tensor]]] = None,
- last_image: Optional[torch.Tensor] = None, # Not supported yet
+ last_image: Optional[Union[ImageSingle, ImageBatch]] = None,
# Other args
compute_log_prob: bool = False,
attention_kwargs: Optional[Dict[str, Any]] = None,
@@ -543,8 +569,6 @@ def inference(
)
height, width = calc_height, calc_width
- images = self._standardize_image_input(images, output_type="pil")
-
# 2. Encode prompt
if prompt_embeds is None:
encoded = self.encode_prompt(
@@ -573,6 +597,17 @@ def inference(
if negative_prompt_embeds is not None:
negative_prompt_embeds = negative_prompt_embeds.to(transformer_dtype)
+ condition_rows = normalize_wan_i2v_image_rows(
+ images,
+ expected_batch_size=batch_size,
+ )
+ condition_rows = append_wan_i2v_last_images(condition_rows, last_image)
+ if self.pipeline.config.expand_timesteps and any(len(row) == 2 for row in condition_rows):
+ raise ValueError(
+ "Wan I2V expand_timesteps does not support an optional last-frame image; "
+ "Diffusers would otherwise ignore it"
+ )
+
# 3. Set scheduler
self.scheduler.set_timesteps(num_inference_steps, device=device)
timesteps = self.scheduler.timesteps
@@ -584,28 +619,50 @@ def inference(
and self.pipeline.transformer.config.image_dim is not None
):
if image_embeds is None:
- image_to_encode = images if last_image is None else [images, last_image]
- image_encoded = self.encode_image(image_to_encode, device)
+ image_encoded = self.encode_image(
+ condition_rows,
+ device,
+ height=height,
+ width=width,
+ )
image_embeds = image_encoded["image_embeds"]
-
- image_embeds = (
- image_embeds.to(device=device, dtype=transformer_dtype)
- if image_embeds is not None
- else None
- )
+ if condition_images is None:
+ condition_images = image_encoded["condition_images"]
+ if image_embeds is not None:
+ image_embeds = normalize_wan_image_embeds(
+ image_embeds,
+ batch_size=batch_size,
+ ).to(device=device, dtype=transformer_dtype)
+ per_sample_image_embeds = split_wan_image_embeds(
+ image_embeds,
+ [len(row) for row in condition_rows],
+ )
+ else:
+ per_sample_image_embeds = (None,) * batch_size
# 5. Prepare latent variables
num_channels_latents = self.pipeline.vae.config.z_dim
- images = self.pipeline.video_processor.preprocess(images, height=height, width=width).to(
- device, dtype=torch.float32
- )
- if last_image is not None:
- last_image = self.pipeline.video_processor.preprocess(
- last_image, height=height, width=width
- ).to(device, dtype=torch.float32)
+ if condition_images is None:
+ images, last_image_pixels = preprocess_wan_i2v_image_rows(
+ self,
+ condition_rows,
+ height=height,
+ width=width,
+ )
+ else:
+ images, last_image_pixels = restore_wan_i2v_condition_pixels(
+ condition_images,
+ batch_size=batch_size,
+ height=height,
+ width=width,
+ device=device,
+ )
+ expected_last = any(len(row) == 2 for row in condition_rows)
+ if expected_last != (last_image_pixels is not None):
+ raise ValueError(
+ "Wan cached condition_images count disagrees with ordered raw input images"
+ )
- # Inside the following function, preparing `latents_condition` requires `latents_mean` and `latents_std`,
- # which depend on `latents` initialized at runtime. Therefore, this part is kept inside inference function and not moved to preprocess_func.
latents_outputs = self.prepare_latents(
image=images,
batch_size=batch_size,
@@ -617,10 +674,10 @@ def inference(
device=device,
generator=generator,
latents=None,
- last_image=last_image,
+ last_image=last_image_pixels,
)
if self.pipeline.config.expand_timesteps:
- # wan 2.2 5b i2v use firt_frame_mask to mask timesteps
+ # Wan 2.2 5B I2V uses first_frame_mask to expand timesteps.
latents, condition, first_frame_mask = latents_outputs
else:
latents, condition = latents_outputs
@@ -657,7 +714,7 @@ def inference(
guidance_scale=guidance_scale,
guidance_scale_2=guidance_scale_2,
image_embeds=image_embeds,
- condition=condition,
+ latent_condition=condition,
first_frame_mask=first_frame_mask,
attention_kwargs=attention_kwargs,
compute_log_prob=current_compute_log_prob,
@@ -712,10 +769,10 @@ def inference(
height=height,
width=width,
# Conditions
- condition_images=images[b],
- condition=condition[b],
- first_frame_mask=first_frame_mask, # Possibly None
- image_embeds=image_embeds[b] if image_embeds is not None else None,
+ condition_images=condition_rows[b],
+ latent_condition=condition[b],
+ first_frame_mask=(first_frame_mask[b] if first_frame_mask is not None else None),
+ image_embeds=per_sample_image_embeds[b],
# Prompt info
prompt=prompt[b] if isinstance(prompt, list) else prompt,
prompt_ids=prompt_ids[b] if prompt_ids is not None else None,
@@ -755,7 +812,7 @@ def forward(
guidance_scale_2: Optional[float] = None,
# Optional for I2V
image_embeds: Optional[torch.Tensor] = None,
- condition: Optional[torch.Tensor] = None,
+ latent_condition: Optional[torch.Tensor] = None,
first_frame_mask: Optional[torch.Tensor] = None,
boundary_timestep: Optional[float] = None,
# Next timestep info
@@ -780,7 +837,7 @@ def forward(
Args:
t: Current timestep tensor.
latents: Current latent representations.
- condition: Condition latents (first frame encoded).
+ latent_condition: Condition latents (first/optional-last frames encoded).
prompt_embeds: Text prompt embeddings.
negative_prompt_embeds: Optional negative prompt embeddings (for CFG).
guidance_scale: CFG scale for transformer (wan2.1 / wan2.2 high-noise).
@@ -809,6 +866,17 @@ def forward(
else self.pipeline.transformer_2.dtype
)
device = latents.device
+ if latent_condition is None:
+ raise ValueError("Wan I2V forward requires realized VAE condition channels")
+ if self.pipeline.config.expand_timesteps and first_frame_mask is None:
+ raise ValueError("Wan I2V expand_timesteps forward requires first_frame_mask")
+ if not self.pipeline.config.expand_timesteps and first_frame_mask is not None:
+ raise ValueError("Wan I2V non-expanded forward must not receive first_frame_mask")
+ if image_embeds is not None:
+ image_embeds = normalize_wan_image_embeds(
+ image_embeds,
+ batch_size=batch_size,
+ ).to(device=device, dtype=dtype)
# Determine boundary timestep
if boundary_timestep is None and self.pipeline.config.boundary_ratio is not None:
@@ -840,13 +908,15 @@ def forward(
# Prepare latent model input based on wan version
if first_frame_mask is not None:
# wan2.2: expand timesteps with mask
- latent_model_input = (1 - first_frame_mask) * condition + first_frame_mask * latents
+ latent_model_input = (
+ 1 - first_frame_mask
+ ) * latent_condition + first_frame_mask * latents
latent_model_input = latent_model_input.to(dtype)
temp_ts = (first_frame_mask[0][0][:, ::2, ::2] * t).flatten()
timestep = temp_ts.unsqueeze(0).expand(batch_size, -1)
else:
# wan2.1: concatenate condition
- latent_model_input = torch.cat([latents, condition], dim=1).to(dtype)
+ latent_model_input = torch.cat([latents, latent_condition], dim=1).to(dtype)
timestep = t.expand(batch_size)
# Conditional forward pass
@@ -873,6 +943,9 @@ def forward(
)[0]
velocity = velocity_uncond + current_guidance_scale * (velocity - velocity_uncond)
+ if not compute_log_prob and next_latents is None and tuple(return_kwargs) == ("velocity",):
+ return UniPCMultistepSDESchedulerOutput(velocity=velocity)
+
# Scheduler step
output = self.scheduler.step(
velocity=velocity,
diff --git a/src/flow_factory/models/wan/wan2_t2v.py b/src/flow_factory/models/wan/wan2_t2v.py
index 8039e0124..908d0b383 100644
--- a/src/flow_factory/models/wan/wan2_t2v.py
+++ b/src/flow_factory/models/wan/wan2_t2v.py
@@ -15,12 +15,7 @@
# src/flow_factory/models/wan/wan2_t2v.py
from __future__ import annotations
-import logging
-import math
-import os
-from collections import defaultdict
from dataclasses import dataclass
-from numbers import Real
from types import MappingProxyType
from typing import Any, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple, Union
@@ -31,15 +26,12 @@
from peft import PeftModel
from PIL import Image
-from ...contracts import GeometrySource, NegativePromptPolicy, RateRequirement
+from ...contracts import BatchCapability, GeometrySource, NegativePromptPolicy, RateRequirement
from ...hparams import *
from ...samples import T2VSample
from ...scheduler import UniPCMultistepSDEScheduler, UniPCMultistepSDESchedulerOutput
-from ...utils.base import filter_kwargs
from ...utils.logger_utils import setup_logger
from ...utils.trajectory_collector import (
- CallbackCollector,
- TrajectoryCollector,
TrajectoryIndicesType,
create_callback_collector,
create_trajectory_collector,
@@ -47,7 +39,13 @@
from ..abc import BaseAdapter
from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
from ..pipeline_contracts import video_output_contract
-from ._output import WanVideoOutputCodec
+from ._output import (
+ WanVideoOutputCodec,
+ configured_wan_video_output_geometry,
+ normalize_wan_video_latents,
+ resample_wan_output_video,
+ validate_wan_encoded_output_geometry,
+)
logger = setup_logger(__name__)
@@ -76,6 +74,7 @@ class Wan2_T2V_Adapter(BaseAdapter):
negative_prompt=NegativePromptPolicy.OPTIONAL,
output_fps=RateRequirement.REQUIRED,
geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
)
def __init__(self, config: Arguments, accelerator: Accelerator):
@@ -265,52 +264,7 @@ def build_output_state_codec(self) -> OutputStateCodec:
def _configured_video_output_geometry(self) -> Tuple[int, int, int, float]:
"""Return configured Wan geometry after exact latent-grid validation."""
- geometry = []
- for name in ("height", "width", "num_frames"):
- value = getattr(self.training_args, name, None)
- if type(value) is not int or value <= 0:
- raise ValueError(
- f"Wan output geometry requires positive integer train.{name}, "
- f"received {value!r}"
- )
- geometry.append(value)
- frame_rate = getattr(self.training_args, "frame_rate", None)
- if isinstance(frame_rate, bool) or not isinstance(frame_rate, Real):
- raise TypeError(
- "Wan output geometry requires finite positive train.frame_rate, "
- f"received {type(frame_rate).__name__}: {frame_rate!r}"
- )
- frame_rate = float(frame_rate)
- if not math.isfinite(frame_rate) or frame_rate <= 0:
- raise ValueError(
- "Wan output geometry requires finite positive train.frame_rate, "
- f"received {frame_rate!r}"
- )
-
- height, width, num_frames = geometry
- temporal_scale = self.pipeline.vae_scale_factor_temporal
- spatial_scale = self.pipeline.vae_scale_factor_spatial
- if (num_frames - 1) % temporal_scale:
- raise ValueError(
- "Wan output num_frames must satisfy "
- f"(num_frames - 1) % {temporal_scale} == 0, received {num_frames}"
- )
- transformer = (
- self.pipeline.transformer
- if self.pipeline.transformer is not None
- else self.pipeline.transformer_2
- )
- if transformer is None:
- raise RuntimeError("Wan output geometry requires one materialized transformer")
- patch_size = transformer.config.patch_size
- height_multiple = spatial_scale * patch_size[1]
- width_multiple = spatial_scale * patch_size[2]
- if height % height_multiple or width % width_multiple:
- raise ValueError(
- "Wan output height/width must be divisible by transformer latent-grid "
- f"multiples {(height_multiple, width_multiple)}, received {(height, width)}"
- )
- return height, width, num_frames, frame_rate
+ return configured_wan_video_output_geometry(self)
@staticmethod
def _resample_output_video(
@@ -321,63 +275,16 @@ def _resample_output_video(
target_fps: float,
) -> np.ndarray:
"""Select deterministic nearest-time frames for configured target cadence."""
- if video.dtype != np.uint8 or video.ndim != 4 or video.shape[-1] != 3:
- raise ValueError(
- "Wan decoded target video must be uint8 RGB shaped (F,H,W,3), "
- f"received dtype={video.dtype}, shape={tuple(video.shape)}"
- )
- if video.shape[0] < 1:
- raise ValueError("Wan decoded target video must contain at least one frame")
- if isinstance(source_fps, bool) or not isinstance(source_fps, Real):
- raise TypeError(
- "Wan target video requires source fps metadata, "
- f"received {type(source_fps).__name__}: {source_fps!r}"
- )
- source_fps = float(source_fps)
- if not math.isfinite(source_fps) or source_fps <= 0:
- raise ValueError(f"Wan target video requires positive finite fps, got {source_fps!r}")
- indices = np.rint(
- np.arange(target_frames, dtype=np.float64) * source_fps / target_fps
- ).astype(np.int64)
- if indices[-1] >= video.shape[0]:
- required_duration = (target_frames - 1) / target_fps
- available_duration = (video.shape[0] - 1) / source_fps
- raise ValueError(
- "Wan target video is too short for configured temporal geometry: "
- f"requires {required_duration:.6f}s, has {available_duration:.6f}s"
- )
- return np.ascontiguousarray(video[indices])
+ return resample_wan_output_video(
+ video,
+ source_fps=source_fps,
+ target_frames=target_frames,
+ target_fps=target_fps,
+ )
def _normalize_output_video_latents(self, latents: torch.Tensor) -> torch.Tensor:
"""Apply the exact inverse of Wan's existing decode normalization."""
- if not isinstance(latents, torch.Tensor) or latents.ndim != 5:
- raise ValueError(
- "Wan VAE target latents must be rank-5 BCFHW, "
- f"received {type(latents).__name__} with shape "
- f"{getattr(latents, 'shape', None)}"
- )
- config = self.vae.config
- z_dim = config.z_dim
- if latents.shape[1] != z_dim:
- raise ValueError(
- f"Wan VAE target latent channels must equal z_dim={z_dim}, "
- f"received {latents.shape[1]}"
- )
- latents_mean = torch.as_tensor(
- config.latents_mean,
- device=latents.device,
- dtype=latents.dtype,
- ).view(1, z_dim, 1, 1, 1)
- inverse_std = (
- torch.as_tensor(
- config.latents_std,
- device=latents.device,
- dtype=latents.dtype,
- )
- .reciprocal()
- .view(1, z_dim, 1, 1, 1)
- )
- return (latents - latents_mean) * inverse_std
+ return normalize_wan_video_latents(self, latents)
def _validate_encoded_output_geometry(
self,
@@ -386,39 +293,7 @@ def _validate_encoded_output_geometry(
encoded: EncodedOutputState,
) -> None:
"""Require encoded signatures and decode metadata to match train geometry."""
- del condition
- height, width, num_frames, frame_rate = self._configured_video_output_geometry()
- if len(encoded.geometry_signatures) != len(media_batch):
- raise ValueError(
- "Wan output codec must return one geometry signature per sample, "
- f"received {len(encoded.geometry_signatures)} for {len(media_batch)}"
- )
- for sample_index, signature in enumerate(encoded.geometry_signatures):
- geometry = signature.media[0]
- received = (
- geometry.height,
- geometry.width,
- geometry.frames,
- geometry.fps,
- )
- expected = (height, width, num_frames, frame_rate)
- if received != expected:
- raise ValueError(
- "Wan encoded output geometry disagrees with configured geometry for "
- f"sample {sample_index}: expected {expected}, received {received}"
- )
- expected_context = {
- "height": height,
- "width": width,
- "num_frames": num_frames,
- "frame_rate": frame_rate,
- }
- for name, expected in expected_context.items():
- if encoded.decode_context.get(name) != expected:
- raise ValueError(
- f"Wan decode_context {name!r} must equal {expected!r}, "
- f"received {encoded.decode_context.get(name)!r}"
- )
+ validate_wan_encoded_output_geometry(self, media_batch, condition, encoded)
def decode_latents(
self, latents: torch.Tensor, output_type: Literal["pt", "pil", "np"] = "pil"
@@ -779,6 +654,9 @@ def forward(
)[0]
velocity = velocity_uncond + current_guidance_scale * (velocity - velocity_uncond)
+ if not compute_log_prob and next_latents is None and tuple(return_kwargs) == ("velocity",):
+ return UniPCMultistepSDESchedulerOutput(velocity=velocity)
+
# 5. Scheduler step
output = self.scheduler.step(
velocity=velocity,
diff --git a/src/flow_factory/trainers/abc.py b/src/flow_factory/trainers/abc.py
index f82186a2d..d84e9b08d 100644
--- a/src/flow_factory/trainers/abc.py
+++ b/src/flow_factory/trainers/abc.py
@@ -1121,6 +1121,7 @@ def _load_inference_components(self, trainable_module_names: List[str]):
ONLINE_EXECUTION_CONTRACT,
)
if execution_contract.acquisition is AcquisitionMode.DATASET:
+ modules_to_load.extend(self.adapter.condition_state_encoding_modules)
modules_to_load.extend(self.adapter.output_state_encoding_modules)
if not self.config.data_args.enable_preprocess:
diff --git a/src/flow_factory/trainers/common/flow_matching.py b/src/flow_factory/trainers/common/flow_matching.py
index 236b9dea7..18ad79d57 100644
--- a/src/flow_factory/trainers/common/flow_matching.py
+++ b/src/flow_factory/trainers/common/flow_matching.py
@@ -164,16 +164,20 @@ def flow_matching_per_sample_loss(
)
squared_errors[name] = (predicted.float() - target.float()).square()
- reduced = adapter.reduce_latent_values(squared_errors, state=noised.state)
+ reduced = adapter.reduce_flow_matching_objective_values(
+ squared_errors,
+ state=noised.state,
+ )
if not isinstance(reduced, torch.Tensor):
raise TypeError(
- "adapter.reduce_latent_values must return torch.Tensor, "
+ "adapter.reduce_flow_matching_objective_values must return torch.Tensor, "
f"received {type(reduced).__name__}"
)
batch_size = next(iter(squared_errors.values())).shape[0]
if reduced.shape != (batch_size,):
raise ValueError(
- "adapter.reduce_latent_values must return one value per sample with shape "
+ "adapter.reduce_flow_matching_objective_values must return one value per sample "
+ "with shape "
f"({batch_size},), received {tuple(reduced.shape)}"
)
if reduced.dtype is not torch.float32:
diff --git a/src/flow_factory/trainers/common/offline_batch.py b/src/flow_factory/trainers/common/offline_batch.py
index 4b25d3e2d..a87712640 100644
--- a/src/flow_factory/trainers/common/offline_batch.py
+++ b/src/flow_factory/trainers/common/offline_batch.py
@@ -21,6 +21,7 @@
import torch
from ...contracts import NON_MODEL_CONDITION_KEYS
+from ...models.condition_state import PreparedConditionState
def move_condition_to_device(
@@ -79,6 +80,30 @@ def bind_output_forward_context(
return {**condition, **forward_context}
+def bind_prepared_condition_output(
+ prepared: PreparedConditionState,
+ output_forward_context: Mapping[str, Any],
+) -> Dict[str, Any]:
+ """Bind one realized input condition to candidate-specific output fields.
+
+ Args:
+ prepared: Input-owned realization shared by every candidate for a request.
+ output_forward_context: Candidate-specific output-derived model fields.
+
+ Returns:
+ A new complete model-forward mapping.
+ """
+ if not isinstance(prepared, PreparedConditionState):
+ raise TypeError(
+ "expected PreparedConditionState for prepared offline condition, "
+ f"received {type(prepared).__name__}: {prepared!r}"
+ )
+ return bind_output_forward_context(
+ prepared.model_forward_condition(),
+ output_forward_context,
+ )
+
+
def _move_condition_value(
value: Any,
device: torch.device,
@@ -136,4 +161,8 @@ def _reject_non_model_keys(value: Mapping[str, Any], identifier: str) -> None:
)
-__all__ = ["bind_output_forward_context", "move_condition_to_device"]
+__all__ = [
+ "bind_output_forward_context",
+ "bind_prepared_condition_output",
+ "move_condition_to_device",
+]
diff --git a/src/flow_factory/trainers/common/runtime_identity.py b/src/flow_factory/trainers/common/runtime_identity.py
index 47f498ba0..356df6f9d 100644
--- a/src/flow_factory/trainers/common/runtime_identity.py
+++ b/src/flow_factory/trainers/common/runtime_identity.py
@@ -128,6 +128,11 @@ def build_default_execution_identity_payload(trainer: Any) -> dict[str, Any]:
execution_contract = type(trainer).execution_contract
acquisition = getattr(execution_contract, "acquisition", None)
feedback = getattr(execution_contract, "feedback", None)
+ pipeline_io_contract = getattr(
+ trainer.adapter,
+ "effective_pipeline_io_contract",
+ None,
+ )
return {
"contract": {
"acquisition": _enum_identity_value(
@@ -140,6 +145,7 @@ def build_default_execution_identity_payload(trainer: Any) -> dict[str, Any]:
),
"paradigm": getattr(type(trainer), "paradigm", None),
},
+ "pipeline_io_contract": pipeline_io_contract,
"training": training,
"scheduler": scheduler,
"realized_scheduler_group": _scheduler_group_schema(trainer.adapter),
diff --git a/src/flow_factory/trainers/offline/offline_dpo.py b/src/flow_factory/trainers/offline/offline_dpo.py
index 31d94d011..1786f1cb7 100644
--- a/src/flow_factory/trainers/offline/offline_dpo.py
+++ b/src/flow_factory/trainers/offline/offline_dpo.py
@@ -34,7 +34,7 @@
validate_preference_component_times,
validate_preference_output_states,
)
-from ..common.offline_batch import bind_output_forward_context, move_condition_to_device
+from ..common.offline_batch import bind_prepared_condition_output, move_condition_to_device
from ..forward_process import forward_velocity_state
MetricAccumulator = Dict[str, List[torch.Tensor]]
@@ -53,7 +53,7 @@ def _build_train_dataloader(self) -> Tuple[DataLoader, Dict[str, DataLoader]]:
accelerator=self.accelerator,
preprocess_func=self.adapter.preprocess_func,
supervision_type="preference",
- pipeline_io_contract=self.adapter.pipeline_io_contract,
+ pipeline_io_contract=self.adapter.effective_pipeline_io_contract,
)
return dataloader, {}
@@ -63,12 +63,19 @@ def optimize_batch(self, batch: Any) -> None:
self.adapter.train()
condition = move_condition_to_device(batch.condition, self.accelerator.device)
- chosen = self.adapter.encode_output_state(preference.chosen_media, condition)
- rejected = self.adapter.encode_output_state(preference.rejected_media, condition)
+ prepared_condition = self.adapter.prepare_condition_state(condition)
+ chosen = self.adapter.encode_output_state(preference.chosen_media, prepared_condition)
+ rejected = self.adapter.encode_output_state(preference.rejected_media, prepared_condition)
validate_preference_output_states(chosen, rejected)
- chosen_batch = bind_output_forward_context(condition, chosen.forward_context)
- rejected_batch = bind_output_forward_context(condition, rejected.forward_context)
+ chosen_batch = bind_prepared_condition_output(
+ prepared_condition,
+ chosen.forward_context,
+ )
+ rejected_batch = bind_prepared_condition_output(
+ prepared_condition,
+ rejected.forward_context,
+ )
all_timesteps = sample_offline_timesteps(
self.training_args,
batch_size=len(preference.chosen_media),
diff --git a/src/flow_factory/trainers/offline/sft.py b/src/flow_factory/trainers/offline/sft.py
index 9e4885fd5..039966fa6 100644
--- a/src/flow_factory/trainers/offline/sft.py
+++ b/src/flow_factory/trainers/offline/sft.py
@@ -31,7 +31,7 @@
flow_matching_per_sample_loss,
sample_offline_timesteps,
)
-from ..common.offline_batch import bind_output_forward_context, move_condition_to_device
+from ..common.offline_batch import bind_prepared_condition_output, move_condition_to_device
from ..forward_process import forward_velocity_state
@@ -48,7 +48,7 @@ def _build_train_dataloader(self) -> Tuple[DataLoader, Dict[str, DataLoader]]:
accelerator=self.accelerator,
preprocess_func=self.adapter.preprocess_func,
supervision_type="demonstration",
- pipeline_io_contract=self.adapter.pipeline_io_contract,
+ pipeline_io_contract=self.adapter.effective_pipeline_io_contract,
)
return dataloader, {}
@@ -69,8 +69,12 @@ def optimize_batch(self, batch: Any) -> None:
# microstep explicitly restores training mode before policy execution.
self.adapter.train()
- encoded = self.adapter.encode_output_state(output.target_media, condition)
- model_batch = bind_output_forward_context(condition, encoded.forward_context)
+ prepared_condition = self.adapter.prepare_condition_state(condition)
+ encoded = self.adapter.encode_output_state(output.target_media, prepared_condition)
+ model_batch = bind_prepared_condition_output(
+ prepared_condition,
+ encoded.forward_context,
+ )
all_timesteps = sample_offline_timesteps(
self.training_args,
batch_size=len(output.target_media),
diff --git a/tests/contracts/test_pipeline_io_contract.py b/tests/contracts/test_pipeline_io_contract.py
index bb8684bb4..73ddabd43 100644
--- a/tests/contracts/test_pipeline_io_contract.py
+++ b/tests/contracts/test_pipeline_io_contract.py
@@ -35,6 +35,7 @@
OutputMediaSequence,
PipelineIOContract,
RateRequirement,
+ resolve_pipeline_input_media_slots,
validate_pipeline_model_input,
validate_pipeline_output_candidate,
)
@@ -153,6 +154,7 @@ class _InputMediaFixture:
type: str
fps: float | None = None
sample_rate: int | None = None
+ slot: str | None = None
@dataclass
@@ -266,6 +268,195 @@ def test_model_input_validation_enforces_counts_and_required_rates() -> None:
)
+def test_semantic_slots_preserve_positional_shorthand_and_support_sparse_binding() -> None:
+ contract = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(
+ InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=1,
+ max_count=2,
+ slots=("first_frame", "last_frame"),
+ ),
+ ),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ ),
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
+
+ positional = _ModelInputFixture(
+ prompt="both",
+ media=(_InputMediaFixture("image"), _InputMediaFixture("image")),
+ )
+ assert resolve_pipeline_input_media_slots(positional, contract) == (
+ "first_frame",
+ "last_frame",
+ )
+
+ last_only = _ModelInputFixture(
+ prompt="end here",
+ media=(_InputMediaFixture("image", slot="last_frame"),),
+ )
+ assert resolve_pipeline_input_media_slots(last_only, contract) == ("last_frame",)
+
+ mixed = _ModelInputFixture(
+ prompt="explicit last first in the manifest",
+ media=(
+ _InputMediaFixture("image", slot="last_frame"),
+ _InputMediaFixture("image"),
+ ),
+ )
+ assert resolve_pipeline_input_media_slots(mixed, contract) == (
+ "last_frame",
+ "first_frame",
+ )
+
+
+def test_multi_slot_rules_require_within_type_positional_semantics() -> None:
+ """A contract cannot claim order-insensitivity while using positional fallback."""
+ with pytest.raises(ValueError, match="multi-slot.*within_type ordering"):
+ InputMediaSpec(
+ rules=(
+ InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=1,
+ max_count=2,
+ slots=("first_frame", "last_frame"),
+ ),
+ ),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ )
+
+
+def test_semantic_slots_reject_unknown_duplicate_and_missing_required_bindings() -> None:
+ rule = InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=1,
+ max_count=2,
+ slots=("first_frame", "last_frame"),
+ required_slots=("first_frame",),
+ )
+ contract = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(rule,),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ ),
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
+
+ with pytest.raises(ValueError, match="requires input media slots.*first_frame"):
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="last only",
+ media=(_InputMediaFixture("image", slot="last_frame"),),
+ ),
+ contract,
+ )
+ with pytest.raises(ValueError, match="slot 'middle_frame' is not accepted"):
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="unknown",
+ media=(_InputMediaFixture("image", slot="middle_frame"),),
+ ),
+ contract,
+ )
+ with pytest.raises(ValueError, match="assigned more than once"):
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="duplicate",
+ media=(
+ _InputMediaFixture("image", slot="first_frame"),
+ _InputMediaFixture("image", slot="first_frame"),
+ ),
+ ),
+ contract,
+ )
+
+
+def test_aggregate_input_constraints_cover_cross_modality_invariants() -> None:
+ video = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.OPTIONAL,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+ audio = MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.OPTIONAL,
+ )
+ contract = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(
+ InputMediaRule(IMAGE_FORMAT, min_count=0, max_count=9),
+ InputMediaRule(video, min_count=0, max_count=3),
+ InputMediaRule(audio, min_count=0, max_count=3),
+ ),
+ binding=InputMediaBinding.ORDERED_REFERENCES,
+ order=InputMediaOrder.GLOBAL,
+ min_total_count=1,
+ max_total_count=12,
+ required_any_types=(MediaType.IMAGE, MediaType.VIDEO),
+ ),
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
+
+ with pytest.raises(ValueError, match="at least 1 input media item"):
+ validate_pipeline_model_input(_ModelInputFixture(prompt="empty"), contract)
+ with pytest.raises(ValueError, match="whose type is in.*image.*video"):
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="audio only",
+ media=(_InputMediaFixture("audio", sample_rate=16000),),
+ ),
+ contract,
+ )
+ validate_pipeline_model_input(
+ _ModelInputFixture(
+ prompt="valid",
+ media=(
+ _InputMediaFixture("audio", sample_rate=16000),
+ _InputMediaFixture("image"),
+ ),
+ ),
+ contract,
+ )
+
+
+@pytest.mark.parametrize(
+ ("min_total_count", "max_total_count", "match"),
+ (
+ (None, 1, "max_total_count cannot be smaller.*per-type minimums"),
+ (3, None, "min_total_count cannot exceed.*per-type maximums"),
+ ),
+)
+def test_aggregate_input_constraints_reject_impossible_rule_combinations(
+ min_total_count: int | None,
+ max_total_count: int | None,
+ match: str,
+) -> None:
+ """Unsatisfiable aggregate and per-type bounds fail at declaration time."""
+ with pytest.raises(ValueError, match=match):
+ InputMediaSpec(
+ rules=(InputMediaRule(IMAGE_FORMAT, min_count=2, max_count=2),),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ min_total_count=min_total_count,
+ max_total_count=max_total_count,
+ )
+
+
@pytest.mark.parametrize(
"kwargs,match",
[
@@ -470,7 +661,7 @@ def test_pipeline_contract_rejects_raw_policy_values_and_algorithm_shape_fields(
def test_input_media_geometry_requires_a_guaranteed_input() -> None:
"""A conditional geometry source cannot rely on an optional-only input layout."""
- with pytest.raises(ValueError, match="requires at least one input media rule"):
+ with pytest.raises(ValueError, match="constraints that guarantee at least one"):
PipelineIOContract(
input_media=InputMediaSpec(
rules=(InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=1),),
@@ -482,3 +673,59 @@ def test_input_media_geometry_requires_a_guaranteed_input() -> None:
geometry_source=GeometrySource.INPUT_MEDIA,
batch_capability=BatchCapability.UNIFORM,
)
+
+
+@pytest.mark.parametrize(
+ "aggregate_fields",
+ [
+ {"min_total_count": 1},
+ {"required_any_types": (MediaType.IMAGE,)},
+ ],
+)
+def test_input_media_geometry_accepts_aggregate_nonempty_guarantees(
+ aggregate_fields: dict[str, object],
+) -> None:
+ contract = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=1),),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ **aggregate_fields,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(items=(IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.INPUT_MEDIA,
+ batch_capability=BatchCapability.UNIFORM,
+ )
+
+ assert contract.geometry_source is GeometrySource.INPUT_MEDIA
+
+
+def test_required_any_types_requires_canonical_media_type_order() -> None:
+ video = MediaFormat(
+ type=MediaType.VIDEO,
+ fps=RateRequirement.OPTIONAL,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+ )
+
+ with pytest.raises(ValueError, match="required_any_types must use canonical type order"):
+ InputMediaSpec(
+ rules=(
+ InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=1),
+ InputMediaRule(format=video, min_count=0, max_count=1),
+ ),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ required_any_types=(MediaType.VIDEO, MediaType.IMAGE),
+ )
+
+
+def test_required_slots_require_declared_slot_order() -> None:
+ with pytest.raises(ValueError, match="required input media slots must use declared slot order"):
+ InputMediaRule(
+ format=IMAGE_FORMAT,
+ min_count=2,
+ max_count=2,
+ slots=("first_frame", "last_frame"),
+ required_slots=("last_frame", "first_frame"),
+ )
diff --git a/tests/data_utils/test_offline_condition_cache.py b/tests/data_utils/test_offline_condition_cache.py
index 926c6fb6f..9f227046c 100644
--- a/tests/data_utils/test_offline_condition_cache.py
+++ b/tests/data_utils/test_offline_condition_cache.py
@@ -15,6 +15,7 @@
import gc
import json
import weakref
+from dataclasses import replace
from pathlib import Path
from typing import Any, Dict, List
@@ -25,6 +26,20 @@
from datasets import Image as HFImage
from PIL import Image
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaRule,
+ InputMediaSpec,
+ MediaFormat,
+ MediaType,
+ NegativePromptPolicy,
+ OutputMediaSequence,
+ PipelineIOContract,
+ RateRequirement,
+)
from flow_factory.data_utils.dataset import GeneralDataset, _cross_chunk_schema_probe_batches
from flow_factory.data_utils.offline_condition_cache import (
build_offline_condition_cache,
@@ -33,10 +48,47 @@
)
from flow_factory.data_utils.offline_dataset import (
OFFLINE_CONDITION_ID_COLUMN,
+ OfflineDataset,
compute_offline_condition_id,
)
+from flow_factory.data_utils.offline_loader import build_offline_dataloader
from flow_factory.data_utils.schema import NormalizedDatasetRecord, normalize_v2_record
+_IMAGE_FORMAT = MediaFormat(
+ type=MediaType.IMAGE,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.NOT_APPLICABLE,
+)
+_SLOTTED_IMAGE_CONTRACT = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(
+ InputMediaRule(
+ format=_IMAGE_FORMAT,
+ min_count=1,
+ max_count=2,
+ slots=("first_frame", "last_frame"),
+ ),
+ ),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.WITHIN_TYPE,
+ ),
+ negative_prompt=NegativePromptPolicy.UNSUPPORTED,
+ output_media=OutputMediaSequence(items=(_IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+)
+_OPTIONAL_IMAGE_CONTRACT = PipelineIOContract(
+ input_media=InputMediaSpec(
+ rules=(InputMediaRule(format=_IMAGE_FORMAT, min_count=0, max_count=1),),
+ binding=InputMediaBinding.GROUPED_BY_TYPE,
+ order=InputMediaOrder.INSENSITIVE,
+ ),
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ output_media=OutputMediaSequence(items=(_IMAGE_FORMAT,)),
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.UNIFORM,
+)
+
class CountingPreprocessor:
def __init__(self) -> None:
@@ -114,6 +166,37 @@ def preprocess(
}
+class OptionalSourcePreprocessor:
+ """Expose stable output keys for optional condition columns across sources."""
+
+ def preprocess(
+ self,
+ prompt: List[str],
+ negative_prompt: List[str],
+ images: List[List[Image.Image]],
+ ) -> Dict[str, torch.Tensor]:
+ return {
+ "prompt_lengths": torch.tensor([len(value) for value in prompt]),
+ "negative_prompt_lengths": torch.tensor([len(value) for value in negative_prompt]),
+ "image_counts": torch.tensor([len(value) for value in images]),
+ }
+
+
+class SingleSamplePreprocessor:
+ def __init__(self) -> None:
+ self.batch_sizes: List[int] = []
+
+ def preprocess(
+ self,
+ prompt: List[str],
+ images: List[List[Image.Image]],
+ **kwargs: Any,
+ ) -> Dict[str, torch.Tensor]:
+ del images, kwargs
+ self.batch_sizes.append(len(prompt))
+ return {"encoded": torch.ones(len(prompt), 1)}
+
+
class ChunkBoundedDatasetSpy:
def __init__(
self,
@@ -324,6 +407,33 @@ def test_projection_contains_only_input_and_identity_columns(tmp_path: Path) ->
assert "private-target.png" not in repr(projected[0])
+def test_projection_canonicalizes_explicit_and_positional_semantic_slots(
+ tmp_path: Path,
+) -> None:
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "first.png")
+ Image.new("RGB", (2, 2), color=(4, 5, 6)).save(tmp_path / "last.png")
+ record = _demonstration_record(
+ tmp_path,
+ input_media=[
+ {"type": "image", "path": "last.png", "slot": "last_frame"},
+ {"type": "image", "path": "first.png"},
+ ],
+ )
+
+ projected = project_offline_condition_dataset(
+ [record],
+ source_name="slotted",
+ ordered_references=False,
+ pipeline_io_contract=_SLOTTED_IMAGE_CONTRACT,
+ )
+
+ assert projected[0]["images"] == [
+ str(tmp_path / "first.png"),
+ str(tmp_path / "last.png"),
+ ]
+ assert projected[0]["image_slots"] == ["first_frame", "last_frame"]
+
+
def test_projection_normalizes_missing_optional_negative_prompts_in_mixed_batch(
tmp_path: Path,
) -> None:
@@ -340,6 +450,69 @@ def test_projection_normalizes_missing_optional_negative_prompts_in_mixed_batch(
assert projected["negative_prompt"] == ["", "bad quality"]
+def test_optional_condition_columns_are_stable_across_offline_sources(
+ tmp_path: Path,
+) -> None:
+ """ConcatDataset batches can mix an all-empty source with a populated source."""
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "reference.png")
+ Image.new("RGB", (2, 2), color=(4, 5, 6)).save(tmp_path / "target.png")
+ empty_record = _demonstration_record(tmp_path)
+ populated_record = _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "reference.png"}],
+ negative_prompt="bad quality",
+ )
+ preprocessor = OptionalSourcePreprocessor()
+
+ empty_cache = build_offline_condition_cache(
+ [empty_record],
+ source_name="empty-source",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ pipeline_io_contract=_OPTIONAL_IMAGE_CONTRACT,
+ preprocessing_batch_size=1,
+ )
+ populated_cache = build_offline_condition_cache(
+ [populated_record],
+ source_name="populated-source",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ pipeline_io_contract=_OPTIONAL_IMAGE_CONTRACT,
+ preprocessing_batch_size=1,
+ )
+ assert set(empty_cache.column_names) == set(populated_cache.column_names)
+
+ empty_dataset = OfflineDataset(
+ [empty_record],
+ empty_cache,
+ source_name="empty-source",
+ source_id=0,
+ supervision_type="demonstration",
+ )
+ populated_dataset = OfflineDataset(
+ [populated_record],
+ populated_cache,
+ source_name="populated-source",
+ source_id=1,
+ supervision_type="demonstration",
+ )
+ loader = build_offline_dataloader(
+ [empty_dataset, populated_dataset],
+ source_weights=[1, 1],
+ batch_size=2,
+ num_replicas=1,
+ rank=0,
+ gradient_accumulation_steps=1,
+ shuffle=False,
+ )
+
+ batch = next(iter(loader))
+ assert batch.condition["image_counts"].tolist() == [0, 1]
+ assert batch.condition["negative_prompt_lengths"].tolist() == [0, 11]
+
+
def test_preference_arms_never_enter_the_condition_projection(tmp_path: Path) -> None:
projected = project_offline_condition_dataset(
[_preference_record(tmp_path)],
@@ -359,6 +532,31 @@ def test_condition_source_hash_is_order_sensitive() -> None:
)
+def test_condition_source_hash_includes_effective_slot_projection() -> None:
+ reversed_rule = replace(
+ _SLOTTED_IMAGE_CONTRACT.input_media.rules[0],
+ slots=("last_frame", "first_frame"),
+ )
+ reversed_contract = replace(
+ _SLOTTED_IMAGE_CONTRACT,
+ input_media=replace(
+ _SLOTTED_IMAGE_CONTRACT.input_media,
+ rules=(reversed_rule,),
+ ),
+ )
+
+ baseline = compute_offline_condition_source_hash(
+ ["same-input"],
+ pipeline_io_contract=_SLOTTED_IMAGE_CONTRACT,
+ )
+ changed = compute_offline_condition_source_hash(
+ ["same-input"],
+ pipeline_io_contract=reversed_contract,
+ )
+
+ assert changed != baseline
+
+
def test_schema_probe_scans_only_bounded_pending_column_chunks() -> None:
dataset = ChunkBoundedDatasetSpy(
[
@@ -523,6 +721,103 @@ def datasets_3_3_compatible_map(
assert cache[row]["image_latent_ids"].shape == (2, 4)
+def test_distributed_condition_cache_uses_one_global_optional_media_schema(
+ tmp_path: Path,
+) -> None:
+ """Disjoint empty/populated ranks consolidate with identical Arrow features."""
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "reference.png")
+ records = [
+ _demonstration_record(tmp_path),
+ _demonstration_record(tmp_path),
+ _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "reference.png"}],
+ ),
+ _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "reference.png"}],
+ ),
+ ]
+ raw_dataset = project_offline_condition_dataset(
+ records,
+ source_name="distributed-optional-images",
+ ordered_references=False,
+ pipeline_io_contract=_OPTIONAL_IMAGE_CONTRACT,
+ )
+ condition_ids = tuple(raw_dataset[OFFLINE_CONDITION_ID_COLUMN])
+ source_hash = compute_offline_condition_source_hash(
+ condition_ids,
+ pipeline_io_contract=_OPTIONAL_IMAGE_CONTRACT,
+ )
+ preprocessor = OptionalImagePreprocessor()
+ merged_cache_path = GeneralDataset.compute_cache_path(
+ dataset_dir=str(tmp_path),
+ split="train",
+ cache_dir=str(tmp_path / "cache"),
+ max_dataset_size=None,
+ preprocess_func=preprocessor.preprocess,
+ preprocess_kwargs={},
+ source_hash_override=source_hash,
+ )
+
+ for rank in range(2):
+ GeneralDataset(
+ dataset_dir=str(tmp_path),
+ split="train",
+ cache_dir=str(tmp_path / "cache"),
+ force_reprocess=True,
+ preprocessing_batch_size=2,
+ preprocess_func=preprocessor.preprocess,
+ num_shards=2,
+ shard_index=rank,
+ image_dir=str(tmp_path),
+ video_dir=str(tmp_path),
+ audio_dir=str(tmp_path),
+ target_arrow_path=GeneralDataset.build_part_arrow_path(
+ merged_cache_path,
+ rank,
+ 2,
+ ),
+ raw_dataset=raw_dataset,
+ source_hash_override=source_hash,
+ passthrough_columns=(OFFLINE_CONDITION_ID_COLUMN,),
+ )
+
+ GeneralDataset.consolidate_parts(merged_cache_path, 2, split="train")
+ merged = GeneralDataset.load_merged(merged_cache_path).processed_dataset
+
+ assert isinstance(merged.features["condition_images"].feature, HFImage)
+ assert merged[0]["condition_images"] == []
+ assert len(merged[2]["condition_images"]) == 1
+
+
+def test_standalone_condition_cache_honors_single_sample_contract(
+ tmp_path: Path,
+) -> None:
+ Image.new("RGB", (2, 2), color=(1, 2, 3)).save(tmp_path / "first.png")
+ records = [
+ _demonstration_record(
+ tmp_path,
+ input_media=[{"type": "image", "path": "first.png", "slot": "first_frame"}],
+ )
+ for _ in range(2)
+ ]
+ preprocessor = SingleSamplePreprocessor()
+
+ build_offline_condition_cache(
+ records,
+ source_name="single-sample",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=preprocessor.preprocess,
+ pipeline_io_contract=_SLOTTED_IMAGE_CONTRACT,
+ force_reprocess=True,
+ preprocessing_batch_size=8,
+ )
+
+ assert preprocessor.batch_sizes == [1, 1]
+
+
def test_ordered_heterogeneous_references_cross_arrow_as_canonical_json(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
diff --git a/tests/data_utils/test_offline_train_data.py b/tests/data_utils/test_offline_train_data.py
index 42982bdea..de53c7310 100644
--- a/tests/data_utils/test_offline_train_data.py
+++ b/tests/data_utils/test_offline_train_data.py
@@ -786,7 +786,12 @@ def fake_create_or_load_dataset(**kwargs: Any) -> SimpleNamespace:
assert call["enable_distributed"] is True
assert call["preprocess_parallelism"] == "global"
raw_dataset = call["base_kwargs"]["raw_dataset"]
- assert set(raw_dataset.column_names) == {"prompt", OFFLINE_CONDITION_ID_COLUMN}
+ assert set(raw_dataset.column_names) == {
+ "prompt",
+ "negative_prompt",
+ OFFLINE_CONDITION_ID_COLUMN,
+ }
+ assert raw_dataset["negative_prompt"] == ["", "", "", ""]
assert "target-0.png" not in repr(raw_dataset[0])
assert "revision" not in repr(raw_dataset[0])
assert isinstance(loader.sampler, DistributedSampler)
diff --git a/tests/data_utils/test_schema.py b/tests/data_utils/test_schema.py
index b8c909f2c..9c09f5a30 100644
--- a/tests/data_utils/test_schema.py
+++ b/tests/data_utils/test_schema.py
@@ -109,6 +109,31 @@ def test_demonstration_normalization_preserves_media_order_and_resolves_paths(
assert normalized.supervision.target.media[0].path == str(absolute_target)
+def test_input_media_slot_is_normalized_but_output_media_rejects_slots(
+ tmp_path: Path,
+) -> None:
+ raw = _demonstration_record(
+ input={
+ "prompt": "End on this frame.",
+ "media": [
+ {
+ "type": "image",
+ "path": "ending.png",
+ "slot": "last_frame",
+ }
+ ],
+ }
+ )
+
+ normalized = normalize_v2_record(raw, dataset_dir=tmp_path)
+
+ assert normalized.model_input.media[0].slot == "last_frame"
+
+ raw["supervision"]["target"]["media"][0]["slot"] = "last_frame"
+ with pytest.raises(ValidationError, match="slot is input-only"):
+ DatasetRecordV2.model_validate(raw)
+
+
def test_preference_normalization_keeps_both_arms_under_one_input(tmp_path: Path) -> None:
raw = _demonstration_record(
supervision={
diff --git a/tests/docs/test_minimax_h3_docs.py b/tests/docs/test_minimax_h3_docs.py
index 2902cb745..bbfc174a4 100644
--- a/tests/docs/test_minimax_h3_docs.py
+++ b/tests/docs/test_minimax_h3_docs.py
@@ -71,7 +71,8 @@ def test_examples_readme_links_h3_and_separates_validation_levels() -> None:
relative_link = f"../{root_link}"
assert relative_link in text
assert (ROOT / root_link).is_file()
- assert "Schema/API validated only" in text
+ assert "schema/API and local offline-path validated" in text
+ assert "GPU validation plan" in text
assert "hardware" in text
assert "reward" in text
assert "61 GB" in text
@@ -80,6 +81,44 @@ def test_examples_readme_links_h3_and_separates_validation_levels() -> None:
assert "NonCommercial" in text
+def test_gpu_validation_plan_declares_the_complete_smoke_matrix() -> None:
+ text = _text("guidance/gpu_validation.md")
+
+ assert "10 x 3 x 4 = 120 jobs" in text
+ for mode in (
+ "sd35-t2i",
+ "bagel-mri2i",
+ "wan-t2v",
+ "wan-i2v-first",
+ "wan-flf2v",
+ "ltx2-t2av",
+ "ltx2-i2av",
+ "h3-t2va",
+ "h3-fl2va",
+ "h3-ref2va",
+ ):
+ assert f"`{mode}`" in text
+ for backend in ("ddp", "zero2", "fsdp2"):
+ assert f"`{backend}`" in text
+ for algorithm in ("grpo", "sft", "offline-dpo", "tdm"):
+ assert f"`{algorithm}`" in text
+ assert "exactly two rank-local dataloader batches" in text
+ assert "two training epochs" in text
+ assert "eval.eval_freq: 0" in text
+ assert "DistributedSampler" in text
+
+
+def test_install_docs_use_the_released_diffusers_runtime() -> None:
+ readme = _text("README.md")
+ dockerfile = _text("docker/docker-cuda/Dockerfile")
+ docker_readme = _text("docker/README.md")
+
+ assert "diffusers>=0.40.0" in readme
+ assert "pip install -e ./diffusers" not in readme
+ assert "pip install -e ./diffusers" not in dockerfile
+ assert "submodule (required)" not in docker_readme
+
+
def test_new_model_guide_documents_component_runtime_boundaries() -> None:
text = _text("guidance/new_model.md")
for required in (
diff --git a/tests/models/ltx2/test_ltx2_adapter_init.py b/tests/models/ltx2/test_ltx2_adapter_init.py
index f1c3b2587..ad362e40b 100644
--- a/tests/models/ltx2/test_ltx2_adapter_init.py
+++ b/tests/models/ltx2/test_ltx2_adapter_init.py
@@ -51,11 +51,18 @@ class PipelineStub:
def __init__(self) -> None:
self.scheduler = FlowMatchEulerDiscreteScheduler(shift=3.0)
self.transformer = TransformerStub()
+ self.vae = torch.nn.Identity()
+ self.audio_vae = torch.nn.Identity()
@property
def components(self) -> Dict[str, Any]:
"""Return the eager component declaration the classic runtime reads."""
- return {"scheduler": self.scheduler, "transformer": self.transformer}
+ return {
+ "scheduler": self.scheduler,
+ "transformer": self.transformer,
+ "vae": self.vae,
+ "audio_vae": self.audio_vae,
+ }
def _config() -> SimpleNamespace:
@@ -195,3 +202,23 @@ def test_constructor_dispatches_lifecycle_calls_in_component_order(cls: type) ->
adapter.set_trajectory_seed(21)
assert seeded == [("video", 21), ("audio", 21)]
+
+
+def test_i2av_preprocess_forwards_cached_negative_prompt_to_text_encoder() -> None:
+ adapter = object.__new__(LTX2_I2AV_Adapter)
+ captured: Dict[str, Any] = {}
+
+ def encode_prompt(**kwargs: Any) -> Dict[str, Any]:
+ captured.update(kwargs)
+ return {"connector_prompt_embeds": torch.zeros(1, 1, 1)}
+
+ adapter.encode_prompt = encode_prompt
+
+ adapter.preprocess_func(
+ prompt=["describe"],
+ negative_prompt=["avoid blur"],
+ images=None,
+ guidance_scale=4.0,
+ )
+
+ assert captured["negative_prompt"] == ["avoid blur"]
diff --git a/tests/models/ltx2/test_ltx2_component_hooks.py b/tests/models/ltx2/test_ltx2_component_hooks.py
index 2dbabbb04..f5cf3bf78 100644
--- a/tests/models/ltx2/test_ltx2_component_hooks.py
+++ b/tests/models/ltx2/test_ltx2_component_hooks.py
@@ -758,3 +758,32 @@ def test_legacy_concatenated_forward_stays_numerically_unchanged(cls: type) -> N
assert torch.allclose(output.next_latents, torch.cat([expected_video, expected_audio], dim=1))
assert torch.allclose(output.velocity, torch.cat([video_velocity, audio_velocity], dim=1))
assert output.log_prob.shape == (BATCH_SIZE,)
+
+
+@pytest.mark.parametrize("cls", [LTX2_T2AV_Adapter, LTX2_I2AV_Adapter])
+def test_neutral_guidance_preserves_raw_velocity_near_clean_endpoint(cls: type) -> None:
+ adapter = _adapter(cls)
+ video = _video_latents()
+ audio = _audio_latents()
+ extra = {"conditioning_mask": _conditioning_mask()} if cls is LTX2_I2AV_Adapter else {}
+
+ output = adapter.forward(
+ t=torch.full((BATCH_SIZE,), 0.06),
+ t_next=torch.zeros(BATCH_SIZE),
+ latents=torch.cat([video, audio], dim=1),
+ video_seq_len=VIDEO_SEQ_LEN,
+ compute_log_prob=False,
+ return_kwargs=["velocity"],
+ preserve_raw_model_velocity=True,
+ **extra,
+ **_forward_kwargs(),
+ )
+
+ expected_video = video * 0.5 + 1.0
+ expected_audio = audio * -0.25 + 2.0
+ assert torch.equal(
+ output.velocity,
+ torch.cat([expected_video, expected_audio], dim=1),
+ )
+ assert adapter.scheduler.steps == []
+ assert adapter.audio_scheduler.steps == []
diff --git a/tests/models/ltx2/test_ltx2_output_codec.py b/tests/models/ltx2/test_ltx2_output_codec.py
new file mode 100644
index 000000000..8fa0df2d4
--- /dev/null
+++ b/tests/models/ltx2/test_ltx2_output_codec.py
@@ -0,0 +1,526 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CPU tests for LTX2's config-driven offline audiovisual boundary."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Any, Optional
+
+import numpy as np
+import pytest
+import torch
+import torchaudio
+
+from flow_factory.contracts import BatchCapability, GeometrySource, MediaType
+from flow_factory.models.ltx2._output import (
+ LTX2AVOutputCodec,
+ LTX2FirstFrameConditionPreparer,
+ decode_ltx2_output_state,
+ ltx2_log_mel_spectrogram,
+ resolve_ltx2_output_geometry,
+ validate_ltx2_encoded_output_geometry,
+)
+from flow_factory.models.ltx2.ltx2_i2av import LTX2_I2AV_Adapter
+from flow_factory.models.ltx2.ltx2_t2av import LTX2_T2AV_Adapter
+from flow_factory.models.output_state import validate_encoded_output_state
+
+BATCH_SIZE = 2
+HEIGHT = 8
+WIDTH = 8
+NUM_FRAMES = 3
+FRAME_RATE = 4.0
+VIDEO_LATENT_CHANNELS = 2
+LATENT_FRAMES = 2
+LATENT_HEIGHT = 4
+LATENT_WIDTH = 4
+VIDEO_SEQUENCE_LENGTH = LATENT_FRAMES * LATENT_HEIGHT * LATENT_WIDTH
+AUDIO_SAMPLE_RATE = 8000
+AUDIO_HOP_LENGTH = 80
+AUDIO_MEL_BINS = 8
+AUDIO_LATENT_CHANNELS = 2
+AUDIO_LATENT_MEL_BINS = 4
+AUDIO_FEATURE_DIM = AUDIO_LATENT_CHANNELS * AUDIO_LATENT_MEL_BINS
+AUDIO_TARGET_SAMPLES = 6000
+AUDIO_LATENT_FRAMES = 38
+
+
+@dataclass(frozen=True)
+class _DecodedMedia:
+ type: str
+ payload: Any
+ fps: Optional[float] = None
+ sample_rate: Optional[int] = None
+
+
+class _ModeOnlyPosterior:
+ """Posterior fake proving target and condition codecs never sample."""
+
+ def __init__(self, value: torch.Tensor) -> None:
+ self.value = value
+ self.mode_calls = 0
+ self.sample_calls = 0
+
+ def mode(self) -> torch.Tensor:
+ self.mode_calls += 1
+ return self.value
+
+ def sample(self, generator: Optional[torch.Generator] = None) -> torch.Tensor:
+ del generator
+ self.sample_calls += 1
+ raise AssertionError("LTX2 offline targets must use posterior mode")
+
+
+class _VideoVAEFake:
+ dtype = torch.float32
+
+ def __init__(self) -> None:
+ self.config = SimpleNamespace(latent_channels=VIDEO_LATENT_CHANNELS, scaling_factor=2.0)
+ self.latents_mean = torch.tensor([0.5, -0.5])
+ self.latents_std = torch.tensor([2.0, 4.0])
+ self.posteriors: list[_ModeOnlyPosterior] = []
+
+ def encode(self, pixels: torch.Tensor) -> Any:
+ if pixels.shape[2] == 1:
+ latents = pixels[:, :VIDEO_LATENT_CHANNELS, :, ::2, ::2]
+ else:
+ latents = pixels[:, :VIDEO_LATENT_CHANNELS, ::2, ::2, ::2]
+ posterior = _ModeOnlyPosterior(latents + 0.25)
+ self.posteriors.append(posterior)
+ return SimpleNamespace(latent_dist=posterior)
+
+
+class _AudioVAEFake:
+ dtype = torch.float32
+
+ def __init__(self) -> None:
+ self.config = SimpleNamespace(
+ sample_rate=AUDIO_SAMPLE_RATE,
+ mel_hop_length=AUDIO_HOP_LENGTH,
+ mel_bins=AUDIO_MEL_BINS,
+ in_channels=2,
+ latent_channels=AUDIO_LATENT_CHANNELS,
+ )
+ self.latents_mean = torch.linspace(-0.4, 0.3, AUDIO_FEATURE_DIM)
+ self.latents_std = torch.linspace(1.0, 1.7, AUDIO_FEATURE_DIM)
+ self.posteriors: list[_ModeOnlyPosterior] = []
+
+ def encode(self, log_mel: torch.Tensor) -> Any:
+ latents = log_mel[:, :AUDIO_LATENT_CHANNELS, ::2, ::2]
+ posterior = _ModeOnlyPosterior(latents)
+ self.posteriors.append(posterior)
+ return SimpleNamespace(latent_dist=posterior)
+
+
+class _VideoProcessorFake:
+ def preprocess_video(
+ self,
+ videos: list[np.ndarray],
+ *,
+ height: int,
+ width: int,
+ ) -> torch.Tensor:
+ assert (height, width) == (HEIGHT, WIDTH)
+ stacked = np.stack(videos)
+ return torch.from_numpy(stacked).permute(0, 4, 1, 2, 3).float().div(127.5).sub(1.0)
+
+
+class _PipelineFake:
+ vae_spatial_compression_ratio = 2
+ vae_temporal_compression_ratio = 2
+ transformer_spatial_patch_size = 1
+ transformer_temporal_patch_size = 1
+ audio_vae_temporal_compression_ratio = 2
+ audio_vae_mel_compression_ratio = 2
+ audio_sampling_rate = AUDIO_SAMPLE_RATE
+ audio_hop_length = AUDIO_HOP_LENGTH
+
+ def __init__(self) -> None:
+ self.video_processor = _VideoProcessorFake()
+
+ @staticmethod
+ def _normalize_latents(
+ latents: torch.Tensor,
+ latents_mean: torch.Tensor,
+ latents_std: torch.Tensor,
+ scaling_factor: float = 1.0,
+ ) -> torch.Tensor:
+ mean = latents_mean.view(1, -1, 1, 1, 1).to(latents)
+ std = latents_std.view(1, -1, 1, 1, 1).to(latents)
+ return (latents - mean) * scaling_factor / std
+
+ @staticmethod
+ def _pack_latents(
+ latents: torch.Tensor,
+ patch_size: int = 1,
+ patch_size_t: int = 1,
+ ) -> torch.Tensor:
+ batch, channels, frames, height, width = latents.shape
+ latents = latents.reshape(
+ batch,
+ -1,
+ frames // patch_size_t,
+ patch_size_t,
+ height // patch_size,
+ patch_size,
+ width // patch_size,
+ patch_size,
+ )
+ return latents.permute(0, 2, 4, 6, 1, 3, 5, 7).flatten(4, 7).flatten(1, 3)
+
+ @staticmethod
+ def _unpack_latents(
+ latents: torch.Tensor,
+ num_frames: int,
+ height: int,
+ width: int,
+ patch_size: int = 1,
+ patch_size_t: int = 1,
+ ) -> torch.Tensor:
+ batch = latents.shape[0]
+ latents = latents.reshape(
+ batch,
+ num_frames,
+ height,
+ width,
+ -1,
+ patch_size_t,
+ patch_size,
+ patch_size,
+ )
+ return latents.permute(0, 4, 1, 5, 2, 6, 3, 7).flatten(6, 7).flatten(4, 5).flatten(2, 3)
+
+ @staticmethod
+ def _pack_audio_latents(latents: torch.Tensor) -> torch.Tensor:
+ return latents.transpose(1, 2).flatten(2, 3)
+
+ @staticmethod
+ def _normalize_audio_latents(
+ latents: torch.Tensor,
+ latents_mean: torch.Tensor,
+ latents_std: torch.Tensor,
+ ) -> torch.Tensor:
+ return (latents - latents_mean.to(latents)) / latents_std.to(latents)
+
+
+class _AdapterFake:
+ def __init__(self) -> None:
+ self.device = torch.device("cpu")
+ self.training_args = SimpleNamespace(
+ height=HEIGHT,
+ width=WIDTH,
+ num_frames=NUM_FRAMES,
+ frame_rate=FRAME_RATE,
+ )
+ self.pipeline = _PipelineFake()
+ self.components = {
+ "vae": _VideoVAEFake(),
+ "audio_vae": _AudioVAEFake(),
+ "transformer": SimpleNamespace(
+ config=SimpleNamespace(
+ in_channels=VIDEO_LATENT_CHANNELS,
+ audio_in_channels=AUDIO_FEATURE_DIM,
+ audio_patch_size=1,
+ audio_patch_size_t=1,
+ # LTX-2.3-only topology flags do not alter the AV target contract.
+ gated_attn=True,
+ cross_attn_mod=True,
+ )
+ ),
+ }
+ self.decode_calls: list[tuple[Any, ...]] = []
+
+ def get_component(self, name: str) -> Any:
+ return self.components[name]
+
+ def get_component_config(self, name: str) -> Any:
+ return self.components[name].config
+
+ def decode_latents(
+ self,
+ video_latents: torch.Tensor,
+ audio_latents: torch.Tensor,
+ **kwargs: Any,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ self.decode_calls.append((video_latents, audio_latents, kwargs))
+ return video_latents, audio_latents
+
+
+def _media_batch() -> tuple[tuple[_DecodedMedia, _DecodedMedia], ...]:
+ samples = []
+ time = torch.arange(AUDIO_TARGET_SAMPLES, dtype=torch.float32) / AUDIO_SAMPLE_RATE
+ for index in range(BATCH_SIZE):
+ frames = np.full(
+ (NUM_FRAMES, HEIGHT, WIDTH, 3),
+ fill_value=32 + index * 48,
+ dtype=np.uint8,
+ )
+ waveform = torch.sin(2 * torch.pi * (220 + index * 30) * time).unsqueeze(0)
+ samples.append(
+ (
+ _DecodedMedia(type="video", payload=frames, fps=FRAME_RATE),
+ _DecodedMedia(
+ type="audio",
+ payload=waveform,
+ sample_rate=AUDIO_SAMPLE_RATE,
+ ),
+ )
+ )
+ return tuple(samples)
+
+
+def _condition_images() -> torch.Tensor:
+ values = torch.linspace(-0.8, 0.8, BATCH_SIZE * 3 * HEIGHT * WIDTH)
+ return values.reshape(BATCH_SIZE, 3, HEIGHT, WIDTH)
+
+
+def test_official_lightricks_log_mel_frontend_matches_torchaudio() -> None:
+ waveforms = torch.linspace(-1.0, 1.0, 2 * 2048).reshape(1, 2, 2048)
+
+ actual = ltx2_log_mel_spectrogram(
+ waveforms,
+ sample_rate=AUDIO_SAMPLE_RATE,
+ hop_length=AUDIO_HOP_LENGTH,
+ mel_bins=AUDIO_MEL_BINS,
+ )
+ frontend = torchaudio.transforms.MelSpectrogram(
+ sample_rate=AUDIO_SAMPLE_RATE,
+ n_fft=1024,
+ win_length=1024,
+ hop_length=AUDIO_HOP_LENGTH,
+ f_min=0.0,
+ f_max=AUDIO_SAMPLE_RATE / 2,
+ n_mels=AUDIO_MEL_BINS,
+ window_fn=torch.hann_window,
+ center=True,
+ pad_mode="reflect",
+ power=1.0,
+ norm="slaney",
+ mel_scale="slaney",
+ )
+ expected = frontend(waveforms).clamp_min(1e-5).log().permute(0, 1, 3, 2)
+
+ assert torch.equal(actual, expected)
+
+
+def test_t2av_codec_encodes_config_driven_joint_mode_state() -> None:
+ adapter = _AdapterFake()
+ geometry = resolve_ltx2_output_geometry(adapter, conditioned=False)
+
+ with torch.no_grad():
+ encoded = LTX2AVOutputCodec(adapter).encode_output_state(_media_batch(), {})
+
+ assert geometry.video.sequence_length == VIDEO_SEQUENCE_LENGTH
+ assert geometry.audio.latent_frames == AUDIO_LATENT_FRAMES
+ assert geometry.audio.target_samples == AUDIO_TARGET_SAMPLES
+ assert encoded.clean_state.component_names == ("video", "audio")
+ assert encoded.clean_state.components["video"].shape == (
+ BATCH_SIZE,
+ VIDEO_SEQUENCE_LENGTH,
+ VIDEO_LATENT_CHANNELS,
+ )
+ assert encoded.clean_state.components["audio"].shape == (
+ BATCH_SIZE,
+ AUDIO_LATENT_FRAMES,
+ AUDIO_FEATURE_DIM,
+ )
+ assert encoded.clean_state.active_masks is None
+ assert encoded.forward_context["video_seq_len"] == VIDEO_SEQUENCE_LENGTH
+ assert encoded.forward_context["audio_num_frames"] == AUDIO_LATENT_FRAMES
+ assert encoded.geometry_signatures[0].media[0].type is MediaType.VIDEO
+ assert encoded.geometry_signatures[0].media[1].type is MediaType.AUDIO
+ assert encoded.geometry_signatures[0].media[1].samples == AUDIO_TARGET_SAMPLES
+ assert all(posterior.mode_calls == 1 for posterior in adapter.components["vae"].posteriors)
+ assert all(
+ posterior.mode_calls == 1 for posterior in adapter.components["audio_vae"].posteriors
+ )
+
+ validate_encoded_output_state(
+ encoded,
+ contract=LTX2_T2AV_Adapter.pipeline_io_contract,
+ expected_component_order=("video", "audio"),
+ expected_batch_size=BATCH_SIZE,
+ device="cpu",
+ )
+ validate_ltx2_encoded_output_geometry(
+ adapter,
+ _media_batch(),
+ {},
+ encoded,
+ conditioned=False,
+ )
+
+
+def test_i2av_preparer_binds_and_masks_the_exact_first_latent_frame() -> None:
+ adapter = _AdapterFake()
+ cached_condition = {
+ "connector_prompt_embeds": torch.zeros(BATCH_SIZE, 2, 3),
+ "condition_images": _condition_images(),
+ }
+
+ with torch.no_grad():
+ prepared = LTX2FirstFrameConditionPreparer(adapter).prepare_condition_state(
+ cached_condition
+ )
+ encoded = LTX2AVOutputCodec(adapter, conditioned=True).encode_output_state(
+ _media_batch(),
+ prepared.output_codec_condition(),
+ )
+
+ assert "condition_images" not in prepared.condition
+ assert prepared.forward_context == {}
+ condition_latents = prepared.output_context["condition_video_latents"]
+ assert condition_latents.shape == (
+ BATCH_SIZE,
+ VIDEO_LATENT_CHANNELS,
+ 1,
+ LATENT_HEIGHT,
+ LATENT_WIDTH,
+ )
+ conditioning_mask = encoded.forward_context["conditioning_mask"]
+ first_frame_tokens = LATENT_HEIGHT * LATENT_WIDTH
+ assert torch.equal(
+ conditioning_mask[:, :first_frame_tokens],
+ torch.ones(BATCH_SIZE, first_frame_tokens),
+ )
+ assert not bool(conditioning_mask[:, first_frame_tokens:].any())
+ assert torch.equal(
+ encoded.clean_state.active_masks["video"].squeeze(-1),
+ ~conditioning_mask.bool(),
+ )
+ assert bool(encoded.clean_state.active_masks["audio"].all())
+
+ unpacked = adapter.pipeline._unpack_latents(
+ encoded.clean_state.components["video"],
+ LATENT_FRAMES,
+ LATENT_HEIGHT,
+ LATENT_WIDTH,
+ )
+ expected_first_frame = adapter.pipeline._normalize_latents(
+ condition_latents,
+ adapter.components["vae"].latents_mean,
+ adapter.components["vae"].latents_std,
+ adapter.components["vae"].config.scaling_factor,
+ )
+ assert torch.equal(unpacked[:, :, :1], expected_first_frame)
+
+ validate_encoded_output_state(
+ encoded,
+ contract=LTX2_I2AV_Adapter.pipeline_io_contract,
+ expected_component_order=("video", "audio"),
+ expected_batch_size=BATCH_SIZE,
+ device="cpu",
+ )
+ validate_ltx2_encoded_output_geometry(
+ adapter,
+ _media_batch(),
+ prepared.output_codec_condition(),
+ encoded,
+ conditioned=True,
+ )
+
+
+def test_shared_decoder_routes_both_components_and_configured_geometry() -> None:
+ adapter = _AdapterFake()
+ with torch.no_grad():
+ encoded = LTX2AVOutputCodec(adapter).encode_output_state(_media_batch(), {})
+
+ decoded = decode_ltx2_output_state(adapter, encoded, output_type="pt")
+
+ assert decoded == (
+ encoded.clean_state.components["video"],
+ encoded.clean_state.components["audio"],
+ )
+ assert adapter.decode_calls[0][2] == {
+ "height": HEIGHT,
+ "width": WIDTH,
+ "num_frames": NUM_FRAMES,
+ "frame_rate": FRAME_RATE,
+ "output_type": "pt",
+ }
+
+
+def test_adapters_declare_complete_offline_av_capability() -> None:
+ LTX2_T2AV_Adapter.validate_offline_output_capability()
+ LTX2_I2AV_Adapter.validate_offline_output_capability()
+
+ assert LTX2_T2AV_Adapter.component_load_dtype_defaults == {"audio_vae": torch.float32}
+ assert LTX2_I2AV_Adapter.component_load_dtype_defaults == {"audio_vae": torch.float32}
+ assert LTX2_T2AV_Adapter.pipeline_io_contract.geometry_source is GeometrySource.CONFIGURED
+ assert LTX2_I2AV_Adapter.pipeline_io_contract.geometry_source is GeometrySource.CONFIGURED
+ assert LTX2_T2AV_Adapter.pipeline_io_contract.batch_capability is BatchCapability.UNIFORM
+ assert LTX2_I2AV_Adapter.pipeline_io_contract.batch_capability is BatchCapability.UNIFORM
+ assert LTX2_T2AV_Adapter.pipeline_io_contract.input_media.rules == ()
+ assert LTX2_I2AV_Adapter.pipeline_io_contract.input_media.rules[0].min_count == 1
+ assert LTX2_I2AV_Adapter.pipeline_io_contract.input_media.rules[0].max_count == 1
+ assert LTX2_T2AV_Adapter.output_state_codec_unavailable_reason is None
+ assert LTX2_I2AV_Adapter.output_state_codec_unavailable_reason is None
+ assert LTX2_T2AV_Adapter.offline_training_forward_overrides == {
+ "guidance_scale": 1.0,
+ "audio_guidance_scale": 1.0,
+ "guidance_rescale": 0.0,
+ "audio_guidance_rescale": 0.0,
+ "stg_scale": 0.0,
+ "audio_stg_scale": 0.0,
+ "spatio_temporal_guidance_blocks": None,
+ "modality_scale": 1.0,
+ "audio_modality_scale": 1.0,
+ "preserve_raw_model_velocity": True,
+ }
+
+ with pytest.raises(TypeError):
+ LTX2_T2AV_Adapter.offline_training_forward_overrides["guidance_scale"] = 2.0
+
+
+@pytest.mark.parametrize(
+ ("adapter_class", "conditioned"),
+ [(LTX2_T2AV_Adapter, False), (LTX2_I2AV_Adapter, True)],
+)
+def test_offline_flow_objective_sums_modalities_without_changing_joint_reducer(
+ adapter_class: type,
+ conditioned: bool,
+) -> None:
+ codec_adapter = _AdapterFake()
+ condition: Any = {}
+ if conditioned:
+ condition = LTX2FirstFrameConditionPreparer(codec_adapter).prepare_condition_state(
+ {"condition_images": _condition_images()}
+ )
+ condition = condition.output_codec_condition()
+ with torch.no_grad():
+ encoded = LTX2AVOutputCodec(codec_adapter, conditioned=conditioned).encode_output_state(
+ _media_batch(),
+ condition,
+ )
+ values = {
+ "video": torch.full_like(encoded.clean_state.components["video"], 2.0),
+ "audio": torch.full_like(encoded.clean_state.components["audio"], 5.0),
+ }
+ if conditioned:
+ values["video"] = values["video"].masked_fill(
+ ~encoded.clean_state.active_masks["video"],
+ 1000.0,
+ )
+ adapter = object.__new__(adapter_class)
+
+ offline = adapter.reduce_flow_matching_objective_values(
+ values,
+ state=encoded.clean_state,
+ )
+ joint = adapter.reduce_latent_values(values, state=encoded.clean_state)
+
+ assert torch.equal(offline, torch.full((BATCH_SIZE,), 7.0))
+ assert not torch.equal(joint, offline)
diff --git a/tests/models/minimax_h3/test_condition_state.py b/tests/models/minimax_h3/test_condition_state.py
new file mode 100644
index 000000000..57d708283
--- /dev/null
+++ b/tests/models/minimax_h3/test_condition_state.py
@@ -0,0 +1,116 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for one-shot MiniMax H3 offline condition realization."""
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from flow_factory.models.minimax_h3._condition import MiniMaxH3ConditionStatePreparer
+from flow_factory.models.minimax_h3.adapters import (
+ MiniMaxH3FL2VAAdapter,
+ MiniMaxH3Ref2VAAdapter,
+ MiniMaxH3T2VAAdapter,
+)
+
+
+def _cached_condition() -> dict[str, object]:
+ return {
+ "prompt_embeds": torch.zeros(1, 2, 4),
+ "position_ids": torch.zeros(1, 9, 3, dtype=torch.float64),
+ "token_tags": torch.zeros(1, 9, dtype=torch.long),
+ "text_indices": torch.tensor([[0, 1]], dtype=torch.long),
+ "audio_indices": torch.tensor([[2, 3, 4]], dtype=torch.long),
+ "video_indices": torch.tensor([[5, 6, 7, 8]], dtype=torch.long),
+ "num_condition_video_rows": torch.tensor([2]),
+ "num_condition_audio_rows": torch.tensor([1]),
+ "condition_latents": [[torch.ones(1, 24, 1, 2, 2)]],
+ "audio_condition_latents": [[torch.ones(1, 32)]],
+ "height": torch.tensor([32]),
+ "width": torch.tensor([32]),
+ "num_frames": torch.tensor([22]),
+ "reference_manifest": "ordered-manifest",
+ }
+
+
+def test_conditioned_adapters_declare_preparer_but_t2va_keeps_identity() -> None:
+ t2va = object.__new__(MiniMaxH3T2VAAdapter)
+ fl2va = object.__new__(MiniMaxH3FL2VAAdapter)
+ ref2va = object.__new__(MiniMaxH3Ref2VAAdapter)
+
+ assert t2va.build_condition_state_preparer() is None
+ assert isinstance(fl2va.build_condition_state_preparer(), MiniMaxH3ConditionStatePreparer)
+ assert isinstance(ref2va.build_condition_state_preparer(), MiniMaxH3ConditionStatePreparer)
+ assert MiniMaxH3ConditionStatePreparer.required_components == ("scheduler",)
+ assert {
+ MiniMaxH3T2VAAdapter.preprocess_cache_version,
+ MiniMaxH3FL2VAAdapter.preprocess_cache_version,
+ MiniMaxH3Ref2VAAdapter.preprocess_cache_version,
+ } == {"minimax-h3-v2"}
+
+
+def test_ref2va_preparer_realizes_prefix_once_and_routes_owned_contexts(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter = SimpleNamespace(workflow="ref2va", pipeline=object())
+ preparer = MiniMaxH3ConditionStatePreparer(adapter)
+ condition = _cached_condition()
+ prefixes = {
+ "video": torch.randn(1, 2, 96),
+ "audio": torch.randn(1, 1, 32),
+ }
+ calls: list[object] = []
+
+ def prepare(pipeline, cached, *, workflow, generator):
+ calls.append((pipeline, cached, workflow, generator))
+ return prefixes
+
+ monkeypatch.setattr(
+ "flow_factory.models.minimax_h3._condition.prepare_h3_condition_prefixes",
+ prepare,
+ )
+ generator = torch.Generator().manual_seed(11)
+
+ prepared = preparer.prepare_condition_state(condition, generator)
+
+ assert calls == [(adapter.pipeline, condition, "ref2va", generator)]
+ assert "condition_latents" not in prepared.condition
+ assert "audio_condition_latents" not in prepared.condition
+ assert "position_ids" not in prepared.condition
+ assert "num_condition_video_rows" not in prepared.condition
+ assert prepared.condition["reference_manifest"] == "ordered-manifest"
+ forward = prepared.model_forward_condition()
+ codec = prepared.output_codec_condition()
+ assert forward["condition_prefixes"] is prefixes
+ assert forward["layout"]["video_indices"].shape == (4,)
+ assert forward["layout"]["num_condition_video_rows"] == 2
+ assert codec["layout"] is forward["layout"]
+ assert "condition_prefixes" not in codec
+ assert prepared.model_forward_condition()["condition_prefixes"]["video"] is prefixes["video"]
+
+
+def test_preparer_rejects_wrong_workflow_and_stale_runtime_prefix() -> None:
+ with pytest.raises(ValueError, match="requires workflow"):
+ MiniMaxH3ConditionStatePreparer(
+ SimpleNamespace(workflow="t2va", pipeline=object())
+ ).prepare_condition_state(_cached_condition())
+
+ condition = _cached_condition()
+ condition["condition_prefixes"] = {"video": torch.empty(1, 0, 96)}
+ with pytest.raises(ValueError, match="must not contain already-realized"):
+ MiniMaxH3ConditionStatePreparer(
+ SimpleNamespace(workflow="fl2va", pipeline=object())
+ ).prepare_condition_state(condition)
diff --git a/tests/models/minimax_h3/test_diffusers_api_contract.py b/tests/models/minimax_h3/test_diffusers_api_contract.py
index d8c8e37ca..9aeca1a8d 100644
--- a/tests/models/minimax_h3/test_diffusers_api_contract.py
+++ b/tests/models/minimax_h3/test_diffusers_api_contract.py
@@ -22,6 +22,7 @@
from PIL import Image
from flow_factory.models.minimax_h3 import dependency
+from flow_factory.models.minimax_h3.blocks import prepare_h3_rollout_state
from flow_factory.models.runtime import ModularPipelineRuntime
EXECUTED_NO_WEIGHT_BLOCKS = frozenset(
@@ -68,9 +69,9 @@ def test_h3_capable_environment_uses_supported_diffusers_release(
del h3_symbols
installed = metadata.version("diffusers")
required = dependency.MINIMAX_H3_DIFFUSERS_MIN_VERSION
- assert Version(installed) >= Version(required), (
- f"MiniMax H3 requires diffusers>={required}, received {installed}"
- )
+ assert Version(installed) >= Version(
+ required
+ ), f"MiniMax H3 requires diffusers>={required}, received {installed}"
def test_real_h3_symbols_preserve_workflows_and_callable_surfaces(h3_symbols) -> None:
@@ -147,9 +148,7 @@ def test_real_h3_blocks_execute_no_weight_pipeline_state_transitions(h3_symbols)
"num_frames": 124,
}
)
- returned_pipeline, t2va_state = h3_symbols.NoKeyframeAnchorsStep()(
- t2va_pipeline, t2va_state
- )
+ returned_pipeline, t2va_state = h3_symbols.NoKeyframeAnchorsStep()(t2va_pipeline, t2va_state)
assert returned_pipeline is t2va_pipeline
assert t2va_state.values["keyframe_anchors"] == ()
executed.add("NoKeyframeAnchorsStep")
@@ -178,9 +177,7 @@ def test_real_h3_blocks_execute_no_weight_pipeline_state_transitions(h3_symbols)
"condition_latents": [torch.zeros(1, 24, 1, 2, 2)],
}
)
- _, condition_state = h3_symbols.PrepareConditionLatentsStep()(
- t2va_pipeline, condition_state
- )
+ _, condition_state = h3_symbols.PrepareConditionLatentsStep()(t2va_pipeline, condition_state)
assert condition_state.values["condition_rows"].shape == (1, 96)
executed.add("PrepareConditionLatentsStep")
@@ -209,6 +206,18 @@ def test_real_h3_blocks_execute_no_weight_pipeline_state_transitions(h3_symbols)
_, resize_state = h3_symbols.ResizeStep()(fl2va_pipeline, resize_state)
assert resize_state.values["keyframe_anchors"] == ("first",)
assert resize_state.values["keyframes"][0].size == (32, 32)
+
+ last_only_state = h3_symbols.PipelineState(
+ values={
+ "image": None,
+ "last_image": Image.new("RGB", (16, 16)),
+ "height": 32,
+ "width": 32,
+ }
+ )
+ _, last_only_state = h3_symbols.ResizeStep()(fl2va_pipeline, last_only_state)
+ assert last_only_state.values["keyframe_anchors"] == ("last",)
+ assert last_only_state.values["keyframes"][0].size == (32, 32)
executed.add("ResizeStep")
fl2va_state = h3_symbols.PipelineState(
@@ -269,9 +278,7 @@ def test_real_h3_blocks_execute_no_weight_pipeline_state_transitions(h3_symbols)
"audio_latents": torch.zeros(2, 32),
}
)
- _, ref_latents_state = h3_symbols.Ref2VAPrepareLatentsStep()(
- ref2va_pipeline, ref_latents_state
- )
+ _, ref_latents_state = h3_symbols.Ref2VAPrepareLatentsStep()(ref2va_pipeline, ref_latents_state)
assert ref_latents_state.values["latents"].shape == (3, 96)
assert ref_latents_state.values["audio_latents"].shape == (3, 32)
executed.add("Ref2VAPrepareLatentsStep")
@@ -282,6 +289,129 @@ def test_real_h3_blocks_execute_no_weight_pipeline_state_transitions(h3_symbols)
)
+def test_shared_rollout_prefix_helper_matches_official_fl2va_chain_and_rng(
+ h3_symbols,
+) -> None:
+ pipeline = h3_symbols.MiniMaxH3ModularPipeline.from_config({"workflow": "fl2va"})
+ scheduler_class = pipeline.get_component_spec("scheduler").type_hint
+ pipeline.register_components(
+ scheduler=scheduler_class(shift=12.0),
+ audio_scheduler=scheduler_class(shift=3.0),
+ )
+ condition = torch.arange(96, dtype=torch.float32).reshape(1, 24, 1, 2, 2) / 100
+ cached = {
+ "num_latent_frames": 1,
+ "latent_height": 2,
+ "latent_width": 2,
+ "num_audio_latents": 1,
+ "num_condition_video_rows": 1,
+ "num_condition_audio_rows": 0,
+ "condition_latents": [condition],
+ "video_indices": torch.tensor([0, 1]),
+ "audio_indices": torch.tensor([2, 3]),
+ }
+ framework_generator = torch.Generator().manual_seed(123)
+ oracle_generator = torch.Generator().manual_seed(123)
+
+ targets, prefixes = prepare_h3_rollout_state(
+ pipeline,
+ cached,
+ workflow="fl2va",
+ generator=framework_generator,
+ )
+
+ oracle_state = h3_symbols.PipelineState(
+ values={
+ **cached,
+ "generator": oracle_generator,
+ "latents": None,
+ "audio_latents": None,
+ }
+ )
+ for block_type in (
+ h3_symbols.PrepareConditionLatentsStep,
+ h3_symbols.PrepareLatentsStep,
+ h3_symbols.FL2VAPrepareLatentsStep,
+ ):
+ _, oracle_state = block_type()(pipeline, oracle_state)
+
+ torch.testing.assert_close(prefixes["video"][0], oracle_state.values["latents"][:1])
+ torch.testing.assert_close(targets.components["video"][0], oracle_state.values["latents"][1:])
+ torch.testing.assert_close(
+ targets.components["audio"][0],
+ oracle_state.values["audio_latents"],
+ )
+ assert prefixes["audio"].shape == (1, 0, 32)
+ torch.testing.assert_close(
+ torch.rand((), generator=framework_generator),
+ torch.rand((), generator=oracle_generator),
+ )
+
+
+def test_shared_rollout_prefix_helper_matches_official_ref2va_audio_order(
+ h3_symbols,
+) -> None:
+ pipeline = h3_symbols.MiniMaxH3ModularPipeline.from_config({"workflow": "ref2va"})
+ scheduler_class = pipeline.get_component_spec("scheduler").type_hint
+ pipeline.register_components(
+ scheduler=scheduler_class(shift=12.0),
+ audio_scheduler=scheduler_class(shift=3.0),
+ )
+ audio_conditions = [
+ torch.full((1, 32), 4.0),
+ torch.stack([torch.full((32,), 5.0), torch.full((32,), 6.0)]),
+ ]
+ cached = {
+ "num_latent_frames": 1,
+ "latent_height": 2,
+ "latent_width": 2,
+ "num_audio_latents": 1,
+ "num_condition_video_rows": 1,
+ "num_condition_audio_rows": 3,
+ "condition_latents": [torch.ones(1, 24, 1, 2, 2)],
+ "audio_condition_latents": audio_conditions,
+ "video_indices": torch.tensor([0, 1]),
+ "audio_indices": torch.tensor([2, 3, 4, 5, 6]),
+ }
+ framework_generator = torch.Generator().manual_seed(321)
+ oracle_generator = torch.Generator().manual_seed(321)
+
+ targets, prefixes = prepare_h3_rollout_state(
+ pipeline,
+ cached,
+ workflow="ref2va",
+ generator=framework_generator,
+ )
+
+ oracle_state = h3_symbols.PipelineState(
+ values={
+ **cached,
+ "generator": oracle_generator,
+ "latents": None,
+ "audio_latents": None,
+ }
+ )
+ for block_type in (
+ h3_symbols.PrepareConditionLatentsStep,
+ h3_symbols.PrepareLatentsStep,
+ h3_symbols.Ref2VAPrepareLatentsStep,
+ ):
+ _, oracle_state = block_type()(pipeline, oracle_state)
+
+ torch.testing.assert_close(prefixes["video"][0], oracle_state.values["latents"][:1])
+ torch.testing.assert_close(prefixes["audio"][0], oracle_state.values["audio_latents"][:3])
+ torch.testing.assert_close(targets.components["video"][0], oracle_state.values["latents"][1:])
+ torch.testing.assert_close(
+ targets.components["audio"][0],
+ oracle_state.values["audio_latents"][3:],
+ )
+ torch.testing.assert_close(prefixes["audio"][0, :, 0], torch.tensor([4.0, 5.0, 6.0]))
+ torch.testing.assert_close(
+ torch.rand((), generator=framework_generator),
+ torch.rand((), generator=oracle_generator),
+ )
+
+
@pytest.mark.parametrize(
("workflow", "target_component", "absent_target"),
[
diff --git a/tests/models/minimax_h3/test_modular_core.py b/tests/models/minimax_h3/test_modular_core.py
index 70fa1bf98..160b02397 100644
--- a/tests/models/minimax_h3/test_modular_core.py
+++ b/tests/models/minimax_h3/test_modular_core.py
@@ -18,6 +18,7 @@
import pytest
import torch
+from flow_factory.models.minimax_h3 import blocks
from flow_factory.models.minimax_h3._common import MINIMAX_H3_COMPONENT_ORDER
from flow_factory.samples import ComponentTimes, LatentState
from flow_factory.scheduler import SDESchedulerOutput
@@ -257,8 +258,6 @@ def _fake_pipeline():
def test_block_executor_propagates_shared_state_and_selects_outputs(monkeypatch):
- from flow_factory.models.minimax_h3 import blocks
-
monkeypatch.setattr(blocks, "require_minimax_h3_support", lambda: FakeSymbols())
pipeline = SimpleNamespace(calls=[])
first = RecordingBlock("first", {"tensor": torch.tensor([1.0])})
@@ -274,8 +273,6 @@ def test_block_executor_propagates_shared_state_and_selects_outputs(monkeypatch)
def test_block_executor_reports_missing_output_and_preserves_exception(monkeypatch):
- from flow_factory.models.minimax_h3 import blocks
-
monkeypatch.setattr(blocks, "require_minimax_h3_support", lambda: FakeSymbols())
with pytest.raises(ValueError, match="workflow='t2va'.*field='missing'"):
blocks.run_h3_blocks(
@@ -302,7 +299,7 @@ def test_block_executor_reports_missing_output_and_preserves_exception(monkeypat
(
"t2va",
["TextEncoderStep", "NoKeyframeAnchorsStep", "PrepareLayoutStep"],
- {"prompt_embeds", "keyframe_anchors", "video_indices", "audio_indices"},
+ {"prompt_embeds", "video_indices", "audio_indices"},
),
(
"fl2va",
@@ -312,7 +309,7 @@ def test_block_executor_reports_missing_output_and_preserves_exception(monkeypat
"KeyframeEncoderStep",
"PrepareLayoutStep",
],
- {"prompt_embeds", "keyframes", "condition_latents", "video_indices"},
+ {"prompt_embeds", "condition_latents", "video_indices"},
),
(
"ref2va",
@@ -324,7 +321,6 @@ def test_block_executor_reports_missing_output_and_preserves_exception(monkeypat
],
{
"prompt_embeds",
- "normalized_references",
"condition_latents",
"audio_condition_latents",
},
@@ -334,8 +330,6 @@ def test_block_executor_reports_missing_output_and_preserves_exception(monkeypat
def test_encoding_workflows_use_pinned_block_order_and_cache_contract(
monkeypatch, workflow, expected_order, required_fields
):
- from flow_factory.models.minimax_h3 import blocks
-
monkeypatch.setattr(blocks, "require_minimax_h3_support", lambda: FakeSymbols())
pipeline = _fake_pipeline()
cache = blocks.encode_h3_workflow_inputs(pipeline, {"prompt": "test"}, workflow=workflow)
@@ -345,6 +339,12 @@ def test_encoding_workflows_use_pinned_block_order_and_cache_contract(
assert set(cache) == set(blocks.ENCODE_WORKFLOW_FIELDS[workflow])
assert "generator" not in cache
assert "text_encoder" not in cache
+ assert not {
+ "keyframes",
+ "keyframe_anchors",
+ "normalized_references",
+ "text_token_tags",
+ }.intersection(cache)
@pytest.mark.parametrize(
@@ -376,8 +376,6 @@ def test_encoding_workflows_use_pinned_block_order_and_cache_contract(
def test_rollout_uses_condition_video_audio_rng_order_and_exact_split(
monkeypatch, workflow, video_conditions, audio_conditions, expected_order
):
- from flow_factory.models.minimax_h3 import blocks
-
monkeypatch.setattr(blocks, "require_minimax_h3_support", lambda: FakeSymbols())
pipeline = _fake_pipeline()
cache = _fake_layout_cache(video_conditions, audio_conditions)
@@ -399,9 +397,34 @@ def test_rollout_uses_condition_video_audio_rng_order_and_exact_split(
assert prefixes["audio"].shape[1] == audio_conditions
-def test_rollout_rejects_full_rows_mismatching_layout_before_split(monkeypatch):
- from flow_factory.models.minimax_h3 import blocks
+def test_condition_prefix_helper_accepts_collated_b1_tensor_containers(monkeypatch):
+ monkeypatch.setattr(blocks, "require_minimax_h3_support", lambda: FakeSymbols())
+ pipeline = _fake_pipeline()
+ cache = _fake_layout_cache(video_conditions=1, audio_conditions=2)
+ cache["video_indices"] = cache["video_indices"].unsqueeze(0)
+ cache["audio_indices"] = cache["audio_indices"].unsqueeze(0)
+ cache["num_condition_video_rows"] = torch.tensor([1])
+ cache["num_condition_audio_rows"] = [2]
+ cache["condition_latents"] = torch.ones(1, 1, 1, 24, 1, 2, 2)
+ cache["audio_condition_latents"] = torch.stack(
+ [torch.full((1, 32), 4.0), torch.full((1, 32), 5.0)]
+ ).unsqueeze(0)
+
+ prefixes = blocks.prepare_h3_condition_prefixes(
+ pipeline,
+ cache,
+ workflow="ref2va",
+ generator=torch.Generator().manual_seed(7),
+ )
+
+ assert pipeline.calls == ["PrepareConditionLatentsStep"]
+ assert [name for name, _ in pipeline.draws] == ["condition"]
+ assert prefixes["video"].shape == (1, 1, 96)
+ assert prefixes["audio"].shape == (1, 2, 32)
+ torch.testing.assert_close(prefixes["audio"][0, :, 0], torch.tensor([4.0, 5.0]))
+
+def test_rollout_rejects_full_rows_mismatching_layout_before_split(monkeypatch):
monkeypatch.setattr(blocks, "require_minimax_h3_support", lambda: FakeSymbols())
cache = _fake_layout_cache(video_conditions=1)
cache["video_indices"] = torch.arange(4)
@@ -423,8 +446,6 @@ def test_rollout_rejects_full_rows_mismatching_layout_before_split(monkeypatch):
def test_rollout_rejects_zero_target_rows_with_exact_diagnostic(monkeypatch):
- from flow_factory.models.minimax_h3 import blocks
-
class EmptyTargetPrepareLatentsStep(FakeWorkflowBlock):
def __call__(self, pipeline, state):
pipeline.calls.append(type(self).__name__)
diff --git a/tests/models/minimax_h3/test_output_codec.py b/tests/models/minimax_h3/test_output_codec.py
index fceacbff9..cd0f92255 100644
--- a/tests/models/minimax_h3/test_output_codec.py
+++ b/tests/models/minimax_h3/test_output_codec.py
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Tests for MiniMax H3 T2VA offline audiovisual target encoding."""
+"""Tests for MiniMax H3 offline audiovisual target encoding."""
from types import SimpleNamespace
from typing import Any, Optional
@@ -24,11 +24,15 @@
from flow_factory.contracts import (
BatchCapability,
GeometrySource,
+ InputMediaBinding,
+ InputMediaOrder,
MediaType,
NegativePromptPolicy,
RateRequirement,
+ validate_pipeline_model_input,
)
from flow_factory.data_utils.offline_dataset import DecodedMedia
+from flow_factory.data_utils.schema import MediaAsset, NormalizedModelInput
from flow_factory.models.minimax_h3._output import (
MiniMaxH3AVOutputCodec,
prepare_h3_target_audio,
@@ -151,9 +155,14 @@ def get_component(self, name: str) -> Any:
return getattr(self, name)
-def _base_adapter(*, latent_storage_dtype: str) -> MiniMaxH3T2VAAdapter:
+def _base_adapter(
+ *,
+ latent_storage_dtype: str,
+ adapter_type: type = MiniMaxH3T2VAAdapter,
+) -> Any:
components = _Adapter()
- adapter = object.__new__(MiniMaxH3T2VAAdapter)
+ components.transformer_ref = components.transformer
+ adapter = object.__new__(adapter_type)
adapter.accelerator = SimpleNamespace(device=torch.device("cpu"))
adapter.training_args = SimpleNamespace(
height=32,
@@ -180,14 +189,20 @@ def _base_adapter(*, latent_storage_dtype: str) -> MiniMaxH3T2VAAdapter:
)
adapter.scheduler.set_timesteps(2, device="cpu")
adapter.audio_scheduler.set_timesteps(2, device="cpu")
+ adapter._effective_pipeline_io_contract = adapter.pipeline_io_contract
+ adapter._condition_state_preparer = adapter.build_condition_state_preparer()
adapter._output_state_codec = adapter.build_output_state_codec()
return adapter
-def _condition() -> dict[str, Any]:
+def _condition(
+ *,
+ video_condition_rows: int = 0,
+ audio_condition_rows: int = 0,
+) -> dict[str, Any]:
text_rows = 2
- audio_rows = 74
- video_rows = 7
+ audio_rows = 74 + audio_condition_rows
+ video_rows = 7 + video_condition_rows
sequence_length = text_rows + audio_rows + video_rows
return {
"height": [32],
@@ -202,8 +217,8 @@ def _condition() -> dict[str, Any]:
"text_indices": torch.arange(text_rows).unsqueeze(0),
"audio_indices": torch.arange(text_rows, text_rows + audio_rows).unsqueeze(0),
"video_indices": torch.arange(text_rows + audio_rows, sequence_length).unsqueeze(0),
- "num_condition_video_rows": [0],
- "num_condition_audio_rows": [0],
+ "num_condition_video_rows": [video_condition_rows],
+ "num_condition_audio_rows": [audio_condition_rows],
}
@@ -243,15 +258,77 @@ def test_t2va_declares_exact_configured_single_sample_av_contract() -> None:
MiniMaxH3T2VAAdapter.validate_offline_output_capability()
-@pytest.mark.parametrize("adapter_type", [MiniMaxH3FL2VAAdapter, MiniMaxH3Ref2VAAdapter])
-def test_conditioned_h3_workflows_require_reproducible_prefix_binder(
- adapter_type: type,
-) -> None:
- with pytest.raises(NotImplementedError, match="conditioned-prefix binder"):
- adapter_type.validate_offline_output_capability()
+def test_fl2va_declares_ordered_first_last_input_and_complete_offline_codec() -> None:
+ contract = MiniMaxH3FL2VAAdapter.pipeline_io_contract
+
+ assert contract is not None
+ assert contract.input_media.binding is InputMediaBinding.GROUPED_BY_TYPE
+ assert contract.input_media.order is InputMediaOrder.WITHIN_TYPE
+ assert len(contract.input_media.rules) == 1
+ rule = contract.input_media.rules[0]
+ assert rule.format.type is MediaType.IMAGE
+ assert (rule.min_count, rule.max_count) == (1, 2)
+ assert rule.slots == ("first_frame", "last_frame")
+ assert rule.required_slots == ()
+ MiniMaxH3FL2VAAdapter.validate_offline_output_capability()
-def test_codec_uses_framework_video_sample_without_condition_fp16_rounding() -> None:
+def test_ref2va_declares_global_ordered_multimodal_input_and_complete_codec() -> None:
+ contract = MiniMaxH3Ref2VAAdapter.pipeline_io_contract
+
+ assert contract is not None
+ assert contract.input_media.binding is InputMediaBinding.ORDERED_REFERENCES
+ assert contract.input_media.order is InputMediaOrder.GLOBAL
+ rules = contract.input_media.rules
+ assert tuple(rule.format.type for rule in rules) == (
+ MediaType.IMAGE,
+ MediaType.VIDEO,
+ MediaType.AUDIO,
+ )
+ assert tuple((rule.min_count, rule.max_count) for rule in rules) == (
+ (0, 9),
+ (0, 3),
+ (0, 3),
+ )
+ assert rules[1].format.fps is RateRequirement.OPTIONAL
+ assert rules[2].format.sample_rate is RateRequirement.OPTIONAL
+ assert contract.input_media.min_total_count == 1
+ assert contract.input_media.max_total_count == 12
+ assert contract.input_media.required_any_types == (MediaType.IMAGE, MediaType.VIDEO)
+ MiniMaxH3Ref2VAAdapter.validate_offline_output_capability()
+
+
+def test_ref2va_contract_fails_before_decode_for_empty_audio_only_and_overall_limit() -> None:
+ contract = MiniMaxH3Ref2VAAdapter.pipeline_io_contract
+
+ def model_input(*media: MediaAsset) -> NormalizedModelInput:
+ return NormalizedModelInput(
+ prompt="describe",
+ negative_prompt=None,
+ media=media,
+ )
+
+ with pytest.raises(ValueError, match="at least 1 input media item"):
+ validate_pipeline_model_input(model_input(), contract)
+ with pytest.raises(ValueError, match="whose type is in.*image.*video"):
+ validate_pipeline_model_input(
+ model_input(MediaAsset(type="audio", path="voice.wav")),
+ contract,
+ )
+ with pytest.raises(ValueError, match="at most 12 input media item"):
+ validate_pipeline_model_input(
+ model_input(
+ *(
+ [MediaAsset(type="image", path=f"image-{index}.png") for index in range(9)]
+ + [MediaAsset(type="video", path=f"video-{index}.mp4") for index in range(3)]
+ + [MediaAsset(type="audio", path="voice.wav")]
+ )
+ ),
+ contract,
+ )
+
+
+def test_codec_uses_deterministic_av_modes_without_condition_fp16_rounding() -> None:
adapter = _Adapter()
generator = torch.Generator().manual_seed(17)
@@ -261,9 +338,9 @@ def test_codec_uses_framework_video_sample_without_condition_fp16_rounding() ->
generator,
)
- assert adapter.video_posterior.sample_calls == 1
- assert adapter.video_posterior.sample_generator is generator
- assert adapter.video_posterior.mode_calls == 0
+ assert adapter.video_posterior.sample_calls == 0
+ assert adapter.video_posterior.sample_generator is None
+ assert adapter.video_posterior.mode_calls == 1
assert adapter.audio_posterior.mode_calls == 1
assert adapter.audio_posterior.sample_calls == 0
assert adapter.vae.encoded_pixels is not None
@@ -273,10 +350,10 @@ def test_codec_uses_framework_video_sample_without_condition_fp16_rounding() ->
assert encoded.clean_state.component_names == ("video", "audio")
assert encoded.clean_state.components["video"].shape == (1, 7, 96)
assert encoded.clean_state.components["audio"].shape == (1, 74, 32)
- sampled_value = adapter.video_posterior.values.flatten()[0]
- condition_rounded_value = sampled_value.to(torch.float16).to(torch.float32)
- assert sampled_value != condition_rounded_value
- assert encoded.clean_state.components["video"].flatten()[0] == sampled_value
+ mode_value = adapter.video_posterior.values.flatten()[0]
+ condition_rounded_value = mode_value.to(torch.float16).to(torch.float32)
+ assert mode_value != condition_rounded_value
+ assert encoded.clean_state.components["video"].flatten()[0] == mode_value
assert encoded.forward_context == {}
assert encoded.decode_context["geometry"] == {
"height": 32,
@@ -327,10 +404,31 @@ def test_geometry_hook_rejects_invalid_input_owned_flat_layout() -> None:
encoded = MiniMaxH3AVOutputCodec(adapter).encode_output_state(_media(), condition)
condition["audio_indices"] = condition["audio_indices"][:, :-1]
- with pytest.raises(ValueError, match="audio layout expected 74 target rows"):
+ with pytest.raises(ValueError, match=r"audio layout expected 0 condition \+ 74 target rows"):
validate_h3_encoded_output_geometry(adapter, _media(), condition, encoded)
+@pytest.mark.parametrize(
+ ("video_condition_rows", "audio_condition_rows"),
+ [(1, 0), (3, 5)],
+)
+def test_codec_accepts_conditioned_layout_while_encoding_target_only_rows(
+ video_condition_rows: int,
+ audio_condition_rows: int,
+) -> None:
+ adapter = _Adapter()
+ condition = _condition(
+ video_condition_rows=video_condition_rows,
+ audio_condition_rows=audio_condition_rows,
+ )
+
+ encoded = MiniMaxH3AVOutputCodec(adapter).encode_output_state(_media(), condition)
+
+ assert encoded.clean_state.components["video"].shape == (1, 7, 96)
+ assert encoded.clean_state.components["audio"].shape == (1, 74, 32)
+ validate_h3_encoded_output_geometry(adapter, _media(), condition, encoded)
+
+
def test_base_lifecycle_casts_clean_state_without_output_context_dtype_drift() -> None:
adapter = _base_adapter(latent_storage_dtype="fp16")
@@ -341,11 +439,30 @@ def test_base_lifecycle_casts_clean_state_without_output_context_dtype_drift() -
assert encoded.forward_context == {}
+def test_offline_flow_objective_sums_modality_means_without_changing_joint_reducer() -> None:
+ adapter = object.__new__(MiniMaxH3T2VAAdapter)
+ values = {
+ "video": torch.full((2, 7, 96), 2.0),
+ "audio": torch.full((2, 74, 32), 5.0),
+ }
+
+ offline = adapter.reduce_flow_matching_objective_values(values)
+ joint = adapter.reduce_latent_values(values)
+
+ assert torch.equal(offline, torch.full((2,), 7.0))
+ assert not torch.equal(joint, offline)
+
+
+@pytest.mark.parametrize(
+ "adapter_type",
+ [MiniMaxH3T2VAAdapter, MiniMaxH3FL2VAAdapter, MiniMaxH3Ref2VAAdapter],
+)
def test_adapter_decode_routes_both_components_with_cached_geometry(
monkeypatch: pytest.MonkeyPatch,
+ adapter_type: type,
) -> None:
encoded = MiniMaxH3AVOutputCodec(_Adapter()).encode_output_state(_media(), _condition())
- adapter = object.__new__(MiniMaxH3T2VAAdapter)
+ adapter = object.__new__(adapter_type)
observed: dict[str, Any] = {}
def decode(self: Any, state: LatentState, **kwargs: Any) -> str:
@@ -353,7 +470,7 @@ def decode(self: Any, state: LatentState, **kwargs: Any) -> str:
observed.update(kwargs)
return "decoded-av"
- monkeypatch.setattr(MiniMaxH3T2VAAdapter, "decode_latents", decode)
+ monkeypatch.setattr(adapter_type, "decode_latents", decode)
assert adapter.decode_output_state(encoded, output_type="np") == "decoded-av"
assert observed["state"] is encoded.clean_state
@@ -498,6 +615,73 @@ def test_h3_offline_dpo_arms_reuse_structured_noise_through_real_forward() -> No
)
+@pytest.mark.parametrize(
+ ("adapter_type", "audio_condition_rows"),
+ [(MiniMaxH3FL2VAAdapter, 0), (MiniMaxH3Ref2VAAdapter, 2)],
+)
+def test_conditioned_h3_prepares_one_prefix_for_codec_and_velocity_forward(
+ monkeypatch: pytest.MonkeyPatch,
+ adapter_type: type,
+ audio_condition_rows: int,
+) -> None:
+ adapter = _base_adapter(latent_storage_dtype="fp32", adapter_type=adapter_type)
+ condition = {
+ **_condition(
+ video_condition_rows=1,
+ audio_condition_rows=audio_condition_rows,
+ ),
+ "prompt_embeds": torch.zeros(1, 2, 4),
+ "condition_latents": [[torch.ones(1, 24, 1, 2, 2)]],
+ "audio_condition_latents": [
+ [torch.ones(audio_condition_rows, 32)] if audio_condition_rows else []
+ ],
+ }
+ prefixes = {
+ "video": torch.full((1, 1, 96), 3.0),
+ "audio": torch.full((1, audio_condition_rows, 32), 4.0),
+ }
+ calls = 0
+
+ def prepare(*args: Any, **kwargs: Any) -> dict[str, torch.Tensor]:
+ nonlocal calls
+ calls += 1
+ return prefixes
+
+ monkeypatch.setattr(
+ "flow_factory.models.minimax_h3._condition.prepare_h3_condition_prefixes",
+ prepare,
+ )
+
+ prepared = adapter.prepare_condition_state(condition)
+ chosen = adapter.encode_output_state(_media(), prepared)
+ rejected = adapter.encode_output_state(_media(), prepared)
+ model_batch = dict(prepared.model_forward_condition())
+ times, noised = build_noised_output_state(
+ adapter,
+ chosen.clean_state,
+ torch.tensor([500.0]),
+ batch=model_batch,
+ generator=torch.Generator().manual_seed(31),
+ )
+ output = adapter._forward_state(
+ batch=SimpleNamespace(),
+ state=noised.state,
+ times=times,
+ next_state=None,
+ compute_log_prob=False,
+ return_fields=("velocity",),
+ noise_level=0.0,
+ forward_kwargs=model_batch,
+ )
+
+ assert calls == 1
+ validate_preference_output_states(chosen, rejected)
+ assert model_batch["condition_prefixes"] is prefixes
+ assert chosen.clean_state.components["video"].shape == (1, 7, 96)
+ assert output.velocity.components["video"].shape == (1, 7, 96)
+ assert output.velocity.components["audio"].shape == (1, 74, 32)
+
+
def test_conditioned_offline_replay_still_requires_prefix_binder() -> None:
adapter = object.__new__(MiniMaxH3FL2VAAdapter)
state = LatentState(
diff --git a/tests/models/minimax_h3/test_workflow_execution.py b/tests/models/minimax_h3/test_workflow_execution.py
index 46b468aa0..334937956 100644
--- a/tests/models/minimax_h3/test_workflow_execution.py
+++ b/tests/models/minimax_h3/test_workflow_execution.py
@@ -16,7 +16,10 @@
import pytest
import torch
+from PIL import Image
+from flow_factory.data_utils.offline_condition_cache import build_offline_condition_cache
+from flow_factory.data_utils.schema import normalize_v2_record
from flow_factory.models.minimax_h3.adapters import (
MiniMaxH3FL2VAAdapter,
MiniMaxH3Ref2VAAdapter,
@@ -63,6 +66,20 @@ def _adapter(adapter_class: type, transformer: Any = None) -> Any:
"num_frames": 5,
},
),
+ (
+ MiniMaxH3FL2VAAdapter,
+ {
+ "images": [["ending"]],
+ "image_slots": [["last_frame"]],
+ },
+ {
+ "prompt": "describe",
+ "last_image": "ending",
+ "height": 64,
+ "width": 96,
+ "num_frames": 5,
+ },
+ ),
],
)
def test_preprocess_uses_exact_workflow_inputs_and_b1(
@@ -87,14 +104,79 @@ def test_preprocess_uses_exact_workflow_inputs_and_b1(
adapter.preprocess_func(prompt=["one", "two"], height=64, width=96, num_frames=5)
+def test_v2_last_only_condition_reaches_h3_preprocess_through_arrow(tmp_path, monkeypatch) -> None:
+ """The complete public-schema/cache path preserves a sparse last-frame binding."""
+ ending_path = tmp_path / "ending.png"
+ Image.new("RGB", (16, 16), color=(12, 34, 56)).save(ending_path)
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {
+ "prompt": "Reveal what led to this ending.",
+ "media": [
+ {
+ "type": "image",
+ "path": ending_path.name,
+ "slot": "last_frame",
+ }
+ ],
+ },
+ "supervision": {
+ "type": "demonstration",
+ "target": {
+ "media": [
+ {"type": "video", "path": "target.mp4", "fps": 24.0},
+ {
+ "type": "audio",
+ "path": "target.wav",
+ "sample_rate": 32000,
+ },
+ ]
+ },
+ },
+ "metadata": {},
+ },
+ dataset_dir=tmp_path,
+ )
+ calls: List[Any] = []
+ monkeypatch.setattr(
+ "flow_factory.models.minimax_h3.workflow.encode_h3_workflow_inputs",
+ lambda pipeline, values, workflow: calls.append((workflow, values))
+ or {"prompt_embeds": torch.zeros(1, 2, 4)},
+ )
+ adapter = _adapter(MiniMaxH3FL2VAAdapter)
+
+ cache = build_offline_condition_cache(
+ [record],
+ source_name="h3-last-only",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=adapter.preprocess_func,
+ preprocess_kwargs={
+ "height": 64,
+ "width": 96,
+ "num_frames": 124,
+ },
+ pipeline_io_contract=adapter.pipeline_io_contract,
+ preprocessing_batch_size=1,
+ )
+
+ assert len(cache) == 1
+ assert len(calls) == 1
+ workflow, values = calls[0]
+ assert workflow == "fl2va"
+ assert "image" not in values
+ assert isinstance(values["last_image"], Image.Image)
+ assert values["last_image"].size == (16, 16)
+
+
def test_preprocess_adds_outer_batch_to_arrow_cache_fields(monkeypatch) -> None:
monkeypatch.setattr(
"flow_factory.models.minimax_h3.workflow.encode_h3_workflow_inputs",
lambda *args, **kwargs: {
"prompt_embeds": torch.zeros(1, 2, 4),
- "text_token_tags": torch.tensor([1, 1]),
+ "token_tags": torch.tensor([1, 1]),
"height": 64,
- "keyframe_anchors": (),
},
)
adapter = _adapter(MiniMaxH3T2VAAdapter)
@@ -107,10 +189,9 @@ def test_preprocess_adds_outer_batch_to_arrow_cache_fields(monkeypatch) -> None:
)
assert result["prompt_embeds"].shape == (1, 2, 4)
- assert len(result["text_token_tags"]) == 1
- torch.testing.assert_close(result["text_token_tags"][0], torch.tensor([1, 1]))
+ assert len(result["token_tags"]) == 1
+ torch.testing.assert_close(result["token_tags"][0], torch.tensor([1, 1]))
assert result["height"] == [64]
- assert result["keyframe_anchors"] == [[]]
def test_ref_preprocess_builds_ordered_pinned_objects_without_returning_them(monkeypatch) -> None:
diff --git a/tests/models/test_offline_output_capability_matrix.py b/tests/models/test_offline_output_capability_matrix.py
index 6305f3812..ee39b1a58 100644
--- a/tests/models/test_offline_output_capability_matrix.py
+++ b/tests/models/test_offline_output_capability_matrix.py
@@ -12,8 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import pytest
-
from flow_factory.models.ltx2.ltx2_i2av import LTX2_I2AV_Adapter
from flow_factory.models.ltx2.ltx2_t2av import LTX2_T2AV_Adapter
from flow_factory.models.minimax_h3.adapters import (
@@ -22,27 +20,20 @@
MiniMaxH3T2VAAdapter,
)
from flow_factory.models.wan.wan2_i2v import Wan2_I2V_Adapter
+from flow_factory.models.wan.wan2_t2v import Wan2_T2V_Adapter
-@pytest.mark.parametrize(
- ("adapter_type", "reason_fragment"),
- [
- (Wan2_I2V_Adapter, "first-frame VAE condition"),
- (LTX2_T2AV_Adapter, "paired video/audio decoding"),
- (LTX2_I2AV_Adapter, "active mask"),
- (MiniMaxH3FL2VAAdapter, "conditioned-prefix binder"),
- (MiniMaxH3Ref2VAAdapter, "conditioned-prefix binder"),
- ],
-)
-def test_unimplemented_offline_media_semantics_fail_before_model_loading(
- adapter_type: type,
- reason_fragment: str,
-) -> None:
- """Expose actionable blockers instead of silently guessing target encoding."""
- with pytest.raises(NotImplementedError, match=reason_fragment):
- adapter_type.validate_offline_output_capability()
+def test_video_and_av_adapters_declare_complete_offline_capability() -> None:
+ """Every implemented video/AV workflow exposes its complete offline codec."""
+ adapter_types = (
+ Wan2_T2V_Adapter,
+ Wan2_I2V_Adapter,
+ LTX2_T2AV_Adapter,
+ LTX2_I2AV_Adapter,
+ MiniMaxH3T2VAAdapter,
+ MiniMaxH3FL2VAAdapter,
+ MiniMaxH3Ref2VAAdapter,
+ )
-
-def test_minimax_h3_t2va_declares_complete_offline_output_semantics() -> None:
- """T2VA has no conditioned prefix and can encode paired AV targets on demand."""
- MiniMaxH3T2VAAdapter.validate_offline_output_capability()
+ for adapter_type in adapter_types:
+ adapter_type.validate_offline_output_capability()
diff --git a/tests/models/test_output_state_adapter_lifecycle.py b/tests/models/test_output_state_adapter_lifecycle.py
index 1b4fad7cb..0653e8673 100644
--- a/tests/models/test_output_state_adapter_lifecycle.py
+++ b/tests/models/test_output_state_adapter_lifecycle.py
@@ -14,7 +14,7 @@
"""Lightweight tests for the BaseAdapter output-state codec lifecycle seam."""
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from types import SimpleNamespace
from typing import Any, Mapping, Optional, Tuple
@@ -35,6 +35,7 @@
RateRequirement,
)
from flow_factory.models.abc import BaseAdapter
+from flow_factory.models.condition_state import PreparedConditionState
from flow_factory.models.output_state import (
DecodedMediaBatch,
EncodedOutputState,
@@ -148,6 +149,38 @@ def encode_output_state(
return self.result or _encoded_image_batch(len(media_batch))
+class _ConditionPreparer:
+ required_components = ("vae",)
+
+ def __init__(self) -> None:
+ self.calls = 0
+ self.grad_enabled: Optional[bool] = None
+
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> PreparedConditionState:
+ del generator
+ self.calls += 1
+ self.grad_enabled = torch.is_grad_enabled()
+ return PreparedConditionState(
+ condition=condition,
+ forward_context={"condition_prefix": torch.zeros(1, 2)},
+ output_context={"codec_condition": torch.ones(1, 2)},
+ )
+
+
+class _StochasticConditionPreparer(_ConditionPreparer):
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: Optional[torch.Generator] = None,
+ ) -> PreparedConditionState:
+ torch.rand((), generator=generator)
+ return super().prepare_condition_state(condition, generator)
+
+
class _LifecycleAdapter(BaseAdapter):
pipeline_io_contract = IMAGE_CONTRACT
@@ -234,6 +267,15 @@ class _DefaultGeometryAdapter(_LifecycleAdapter):
_validate_encoded_output_geometry = BaseAdapter._validate_encoded_output_geometry
+class _PreparedLifecycleAdapter(_LifecycleAdapter):
+ def __init__(self, codec: Optional[_Codec], preparer: _ConditionPreparer) -> None:
+ self._preparer_to_build = preparer
+ super().__init__(codec)
+
+ def build_condition_state_preparer(self) -> _ConditionPreparer:
+ return self._preparer_to_build
+
+
def test_codec_build_runs_after_component_and_scheduler_lifecycle() -> None:
codec = _Codec()
@@ -244,6 +286,48 @@ def test_codec_build_runs_after_component_and_scheduler_lifecycle() -> None:
assert adapter.output_state_encoding_modules == ("vae",)
+def test_condition_preparer_is_declaration_only_and_runs_under_no_grad() -> None:
+ preparer = _ConditionPreparer()
+ adapter = _PreparedLifecycleAdapter(_Codec(), preparer)
+
+ prepared = adapter.prepare_condition_state({"prompt_embeds": torch.ones(1, 2)})
+
+ assert adapter.condition_state_preparer is preparer
+ assert adapter.condition_state_encoding_modules == ("vae",)
+ assert preparer.calls == 1
+ assert preparer.grad_enabled is False
+ assert tuple(prepared.forward_context) == ("condition_prefix",)
+ assert tuple(prepared.output_context) == ("codec_condition",)
+
+
+def test_raw_condition_encode_routes_through_declared_preparer() -> None:
+ preparer = _ConditionPreparer()
+ codec = _Codec()
+ adapter = _PreparedLifecycleAdapter(codec, preparer)
+ condition = {"prompt_embeds": torch.ones(1, 2)}
+
+ adapter.encode_output_state(_image_batch(1), condition)
+
+ assert preparer.calls == 1
+ assert codec.received is not None
+ assert tuple(codec.received[1]) == ("prompt_embeds", "codec_condition")
+ assert torch.equal(codec.received[1]["prompt_embeds"], condition["prompt_embeds"])
+ assert torch.equal(codec.received[1]["codec_condition"], torch.ones(1, 2))
+
+
+def test_default_condition_preparation_is_identity() -> None:
+ adapter = _LifecycleAdapter(_Codec())
+ condition = {"prompt_embeds": torch.ones(1, 2)}
+
+ prepared = adapter.prepare_condition_state(condition)
+
+ assert adapter.condition_state_preparer is None
+ assert adapter.condition_state_encoding_modules == ()
+ assert dict(prepared.condition) == condition
+ assert not prepared.forward_context
+ assert not prepared.output_context
+
+
def test_codec_build_must_remain_declaration_only() -> None:
class MaterializingCodecAdapter(_LifecycleAdapter):
def build_output_state_codec(self) -> Optional[_Codec]:
@@ -263,6 +347,22 @@ def test_contract_without_codec_preserves_online_adapter_construction() -> None:
adapter.encode_output_state(_image_batch(1), {})
+def test_effective_pipeline_contract_can_narrow_checkpoint_capability() -> None:
+ class SingleSampleAdapter(_LifecycleAdapter):
+ def _resolve_pipeline_io_contract(self) -> PipelineIOContract:
+ return replace(
+ self.pipeline_io_contract,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
+
+ adapter = SingleSampleAdapter(_Codec())
+
+ assert adapter.pipeline_io_contract.batch_capability is BatchCapability.UNIFORM
+ assert adapter.effective_pipeline_io_contract.batch_capability is BatchCapability.SINGLE_SAMPLE
+ with pytest.raises(ValueError, match=r"single_sample.*batch size 1.*2"):
+ adapter.encode_output_state(_image_batch(2), {})
+
+
def test_known_codec_blocker_is_actionable_at_direct_encode_boundary() -> None:
class KnownUnavailableAdapter(_OnlineOnlyAdapter):
output_state_codec_unavailable_reason = (
@@ -321,6 +421,12 @@ def test_codec_required_components_must_exist_in_runtime() -> None:
def test_public_output_state_wrapper_cannot_be_overridden() -> None:
+ with pytest.raises(TypeError, match=r"must not override BaseAdapter.prepare_condition_state"):
+
+ class InvalidConditionAdapter(_LifecycleAdapter):
+ def prepare_condition_state(self, *args: Any, **kwargs: Any) -> Any:
+ return None
+
with pytest.raises(TypeError, match=r"must not override BaseAdapter.encode_output_state"):
class InvalidAdapter(_LifecycleAdapter):
@@ -363,12 +469,12 @@ def test_encode_output_state_validates_invokes_no_grad_and_applies_storage_dtype
assert codec.grad_enabled is False
assert codec.received is not None
assert codec.received[0] is media_batch
- assert codec.received[1] is condition
+ assert dict(codec.received[1]) == condition
assert codec.received[2] is generator
assert encoded.clean_state.components["latent"].dtype is torch.float16
assert adapter.geometry_validation is not None
assert adapter.geometry_validation[0] is media_batch
- assert adapter.geometry_validation[1] is condition
+ assert dict(adapter.geometry_validation[1]) == condition
assert adapter.geometry_validation[2] is encoded
@@ -384,6 +490,20 @@ def test_encode_output_state_rejects_candidate_before_invoking_codec() -> None:
assert adapter.geometry_validation is None
+def test_encode_output_state_rejects_candidate_before_stochastic_preparation() -> None:
+ preparer = _StochasticConditionPreparer()
+ adapter = _PreparedLifecycleAdapter(_Codec(), preparer)
+ generator = torch.Generator().manual_seed(7)
+ original_state = generator.get_state().clone()
+ wrong_type = ((_DecodedMedia(type="video", payload=torch.zeros(1)),),)
+
+ with pytest.raises(ValueError, match=r"expected.*type 'image'.*'video'"):
+ adapter.encode_output_state(wrong_type, {}, generator)
+
+ assert preparer.calls == 0
+ assert torch.equal(generator.get_state(), original_state)
+
+
@pytest.mark.parametrize(
("condition", "generator", "message"),
[
diff --git a/tests/models/test_wan_output_codec.py b/tests/models/test_wan_output_codec.py
index ce2a0b1a3..e3dab0ae4 100644
--- a/tests/models/test_wan_output_codec.py
+++ b/tests/models/test_wan_output_codec.py
@@ -12,16 +12,38 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from pathlib import Path
from types import SimpleNamespace
from typing import Any
import numpy as np
import pytest
import torch
-
-from flow_factory.contracts import GeometrySource, MediaType, RateRequirement
-from flow_factory.data_utils.offline_dataset import DecodedMedia
+from PIL import Image
+
+from flow_factory.contracts import (
+ BatchCapability,
+ GeometrySource,
+ InputMediaOrder,
+ MediaType,
+ RateRequirement,
+)
+from flow_factory.data_utils.offline_condition_cache import build_offline_condition_cache
+from flow_factory.data_utils.offline_dataset import (
+ OFFLINE_CONDITION_ID_COLUMN,
+ DecodedMedia,
+ _collate_condition_mappings,
+)
+from flow_factory.data_utils.schema import normalize_v2_record
+from flow_factory.models.abc import BaseAdapter
+from flow_factory.models.wan._conditioning import (
+ WanI2VConditionStatePreparer,
+ normalize_wan_i2v_image_rows,
+ normalize_wan_image_embeds,
+ split_wan_image_embeds,
+)
from flow_factory.models.wan._output import WanVideoOutputCodec
+from flow_factory.models.wan.wan2_i2v import Wan2_I2V_Adapter, WanI2VSample
from flow_factory.models.wan.wan2_t2v import Wan2_T2V_Adapter
@@ -29,11 +51,16 @@ class _Posterior:
def __init__(self, latents: torch.Tensor) -> None:
self.latents = latents
self.generators: list[torch.Generator | None] = []
+ self.mode_calls = 0
def sample(self, generator: torch.Generator | None = None) -> torch.Tensor:
self.generators.append(generator)
return self.latents
+ def mode(self) -> torch.Tensor:
+ self.mode_calls += 1
+ return self.latents
+
class _VAE:
dtype = torch.float32
@@ -56,6 +83,23 @@ def encode(self, pixels: torch.Tensor) -> Any:
class _VideoProcessor:
def __init__(self) -> None:
self.videos: list[list[np.ndarray]] = []
+ self.images: list[list[Image.Image]] = []
+
+ def preprocess(
+ self,
+ images: list[Image.Image],
+ *,
+ height: int,
+ width: int,
+ ) -> torch.Tensor:
+ self.images.append(images)
+ channels = [
+ torch.tensor(np.asarray(image, dtype=np.float32)[0, 0] / 255.0)
+ .view(3, 1, 1)
+ .expand(3, height, width)
+ for image in images
+ ]
+ return torch.stack(channels, dim=0)
def preprocess_video(
self,
@@ -68,6 +112,33 @@ def preprocess_video(
return torch.zeros(len(videos), 3, videos[0].shape[0], height, width)
+class _ProcessorOutput(dict):
+ def to(self, device: torch.device) -> "_ProcessorOutput":
+ del device
+ return self
+
+
+class _ImageProcessor:
+ def __call__(self, *, images: list[Image.Image], return_tensors: str) -> _ProcessorOutput:
+ assert return_tensors == "pt"
+ return _ProcessorOutput(pixel_values=torch.zeros(len(images), 3, 2, 2))
+
+
+class _ImageEncoder:
+ device = torch.device("cpu")
+
+ def __call__(
+ self,
+ pixel_values: torch.Tensor,
+ *,
+ output_hidden_states: bool,
+ ) -> Any:
+ assert output_hidden_states is True
+ count = pixel_values.shape[0]
+ embeds = torch.arange(count * 3 * 5, dtype=torch.float32).view(count, 3, 5)
+ return SimpleNamespace(hidden_states=(embeds, torch.zeros_like(embeds)))
+
+
class _Adapter:
_configured_video_output_geometry = Wan2_T2V_Adapter._configured_video_output_geometry
_resample_output_video = staticmethod(Wan2_T2V_Adapter._resample_output_video)
@@ -88,8 +159,35 @@ def __init__(self) -> None:
transformer=SimpleNamespace(config=SimpleNamespace(patch_size=(1, 2, 2))),
transformer_2=None,
video_processor=_VideoProcessor(),
+ config=SimpleNamespace(expand_timesteps=False),
+ )
+
+
+class _WanPreprocessHarness:
+ """Exercise BaseAdapter preprocessing with Wan's real image encoder hook."""
+
+ preprocess_func = BaseAdapter.preprocess_func
+ encode_image = Wan2_I2V_Adapter.encode_image
+ encode_video = BaseAdapter.encode_video
+ encode_audio = BaseAdapter.encode_audio
+ python_format_columns = frozenset()
+ supports_ordered_references = False
+
+ def __init__(self, *, with_clip: bool = False) -> None:
+ self.device = torch.device("cpu")
+ self.training_args = SimpleNamespace(height=16, width=16)
+ self.image_encoder = _ImageEncoder()
+ self.pipeline = SimpleNamespace(
+ transformer=SimpleNamespace(config=SimpleNamespace(image_dim=8 if with_clip else None)),
+ video_processor=_VideoProcessor(),
+ image_processor=_ImageProcessor(),
+ image_encoder=self.image_encoder,
)
+ def encode_prompt(self, prompt: list[str], **kwargs: Any) -> dict[str, torch.Tensor]:
+ del kwargs
+ return {"prompt_embeds": torch.ones(len(prompt), 3, 4)}
+
def _media(video: np.ndarray, fps: float = 8.0):
return (
@@ -104,12 +202,20 @@ def _media(video: np.ndarray, fps: float = 8.0):
)
+def _condition_pixels(count: int = 1) -> torch.Tensor:
+ pixels = [torch.zeros(3, 16, 16)]
+ if count == 2:
+ pixels.append(torch.ones(3, 16, 16))
+ return torch.stack(pixels, dim=0).unsqueeze(0)
+
+
def test_wan_t2v_declares_required_video_output_semantics() -> None:
contract = Wan2_T2V_Adapter.pipeline_io_contract
assert contract.geometry_source is GeometrySource.CONFIGURED
assert contract.output_media.items[0].type is MediaType.VIDEO
assert contract.output_media.items[0].fps is RateRequirement.REQUIRED
+ assert contract.batch_capability is BatchCapability.SINGLE_SAMPLE
Wan2_T2V_Adapter.validate_offline_output_capability()
adapter = object.__new__(Wan2_T2V_Adapter)
@@ -187,3 +293,242 @@ def test_wan_geometry_validator_rejects_output_context_drift() -> None:
{},
drifted,
)
+
+
+def test_wan_i2v_declares_ordered_first_optional_last_offline_contract() -> None:
+ contract = Wan2_I2V_Adapter.pipeline_io_contract
+
+ assert contract.geometry_source is GeometrySource.CONFIGURED
+ assert contract.batch_capability is BatchCapability.SINGLE_SAMPLE
+ assert contract.input_media.order is InputMediaOrder.WITHIN_TYPE
+ assert len(contract.input_media.rules) == 1
+ rule = contract.input_media.rules[0]
+ assert (rule.format.type, rule.min_count, rule.max_count) == (MediaType.IMAGE, 1, 2)
+ assert dict(Wan2_I2V_Adapter.offline_training_forward_overrides) == {
+ "guidance_scale": 1.0,
+ "guidance_scale_2": 1.0,
+ }
+ Wan2_I2V_Adapter.validate_offline_output_capability()
+
+ adapter = object.__new__(Wan2_I2V_Adapter)
+ assert isinstance(adapter.build_condition_state_preparer(), WanI2VConditionStatePreparer)
+ codec = adapter.build_output_state_codec()
+ assert isinstance(codec, WanVideoOutputCodec)
+ assert codec.bind_condition_active_mask is True
+
+ runtime = object.__new__(Wan2_I2V_Adapter)
+ runtime.pipeline = _Adapter().pipeline
+ effective = Wan2_I2V_Adapter._resolve_pipeline_io_contract(runtime)
+ assert effective.input_media.rules[0].max_count == 2
+ runtime.pipeline.config.expand_timesteps = True
+ effective = Wan2_I2V_Adapter._resolve_pipeline_io_contract(runtime)
+ assert effective.input_media.rules[0].max_count == 1
+
+
+def test_wan_i2v_condition_preparer_preserves_order_and_uses_posterior_mode() -> None:
+ adapter = _Adapter()
+ first = Image.new("RGB", (4, 4), color="red")
+ last = Image.new("RGB", (4, 4), color="blue")
+ image_embeds = torch.ones(1, 2, 4, 6)
+
+ prepared = WanI2VConditionStatePreparer(adapter).prepare_condition_state(
+ {
+ "images": [[first, last]],
+ "condition_images": _condition_pixels(2),
+ "prompt_embeds": torch.ones(1, 3, 5),
+ "image_embeds": image_embeds,
+ }
+ )
+
+ assert tuple(prepared.condition) == ("prompt_embeds",)
+ assert tuple(prepared.forward_context) == ("latent_condition", "image_embeds")
+ assert prepared.output_context == {}
+ assert prepared.forward_context["image_embeds"].shape == (2, 4, 6)
+ condition = prepared.forward_context["latent_condition"]
+ assert condition.shape == (1, 7, 2, 2, 2)
+ torch.testing.assert_close(condition[:, :4, 0], torch.ones(1, 4, 2, 2))
+ expected_last_mask = torch.tensor([0.0, 0.0, 0.0, 1.0]).view(1, 4, 1, 1)
+ torch.testing.assert_close(
+ condition[:, :4, 1],
+ expected_last_mask.expand(1, 4, 2, 2),
+ )
+ assert adapter.vae.posterior.mode_calls == 1
+ assert adapter.vae.posterior.generators == []
+ encoded_pixels = adapter.vae.encoded_pixels[0]
+ torch.testing.assert_close(encoded_pixels[:, :, 0], torch.zeros(1, 3, 16, 16))
+ torch.testing.assert_close(encoded_pixels[:, :, -1], torch.ones(1, 3, 16, 16))
+
+
+def test_wan_i2v_first_only_non_expanded_condition_has_no_output_mask() -> None:
+ adapter = _Adapter()
+ first = Image.new("RGB", (4, 4))
+
+ prepared = WanI2VConditionStatePreparer(adapter).prepare_condition_state(
+ {"images": [[first]], "condition_images": _condition_pixels()}
+ )
+
+ condition = prepared.forward_context["latent_condition"]
+ torch.testing.assert_close(condition[:, :4, 0], torch.ones(1, 4, 2, 2))
+ torch.testing.assert_close(condition[:, :4, 1], torch.zeros(1, 4, 2, 2))
+ assert "first_frame_mask" not in prepared.forward_context
+ assert prepared.output_context == {}
+
+
+def test_wan_i2v_expand_condition_binds_target_active_mask() -> None:
+ adapter = _Adapter()
+ adapter.pipeline.config.expand_timesteps = True
+ first = Image.new("RGB", (4, 4))
+ prepared = WanI2VConditionStatePreparer(adapter).prepare_condition_state(
+ {"images": [[first]], "condition_images": _condition_pixels()}
+ )
+
+ condition = prepared.forward_context["latent_condition"]
+ mask = prepared.forward_context["first_frame_mask"]
+ assert condition.shape == (1, 3, 2, 2, 2)
+ assert mask.shape == (1, 1, 2, 2, 2)
+ torch.testing.assert_close(mask[:, :, 0], torch.zeros(1, 1, 2, 2))
+ torch.testing.assert_close(mask[:, :, 1], torch.ones(1, 1, 2, 2))
+ assert prepared.output_context["first_frame_mask"] is mask
+
+ source = np.zeros((9, 4, 4, 3), dtype=np.uint8)
+ encoded = WanVideoOutputCodec(adapter, bind_condition_active_mask=True).encode_output_state(
+ _media(source),
+ prepared.output_codec_condition(),
+ )
+
+ active_mask = encoded.clean_state.active_masks["latent"]
+ assert active_mask.dtype is torch.bool
+ torch.testing.assert_close(active_mask, mask.bool())
+ assert adapter.vae.posterior.mode_calls == 1
+ assert adapter.vae.posterior.generators == [None]
+
+
+def test_wan_i2v_expand_rejects_last_frame_instead_of_ignoring_it() -> None:
+ adapter = _Adapter()
+ adapter.pipeline.config.expand_timesteps = True
+ first = Image.new("RGB", (4, 4))
+ last = Image.new("RGB", (4, 4))
+
+ with pytest.raises(ValueError, match="expand_timesteps does not support"):
+ WanI2VConditionStatePreparer(adapter).prepare_condition_state(
+ {"images": [[first, last]], "condition_images": _condition_pixels(2)}
+ )
+
+ assert adapter.vae.encoded_pixels == []
+
+
+def test_wan_i2v_expand_target_requires_prepared_active_mask_before_vae() -> None:
+ adapter = _Adapter()
+ adapter.pipeline.config.expand_timesteps = True
+
+ with pytest.raises(ValueError, match="requires first_frame_mask"):
+ WanVideoOutputCodec(adapter, bind_condition_active_mask=True).encode_output_state(
+ _media(np.zeros((9, 4, 4, 3), dtype=np.uint8)),
+ {},
+ )
+
+ assert adapter.vae.encoded_pixels == []
+ assert adapter.vae.posterior.generators == []
+
+
+def test_wan_i2v_normalization_never_truncates_optional_last_frame() -> None:
+ first = Image.new("RGB", (4, 4))
+ last = Image.new("RGB", (4, 4))
+
+ rows = normalize_wan_i2v_image_rows([[first, last]], expected_batch_size=1)
+
+ assert rows == ((first, last),)
+
+
+def test_wan_i2v_online_prepare_latents_reuses_condition_mode_path() -> None:
+ adapter = _Adapter()
+ first_pixels = torch.zeros(1, 3, 16, 16)
+ last_pixels = torch.ones(1, 3, 16, 16)
+ rollout_noise = torch.zeros(1, 3, 2, 2, 2)
+
+ latents, condition = Wan2_I2V_Adapter.prepare_latents(
+ adapter,
+ first_pixels,
+ batch_size=1,
+ num_channels_latents=3,
+ height=16,
+ width=16,
+ num_frames=5,
+ dtype=torch.float32,
+ device=torch.device("cpu"),
+ latents=rollout_noise,
+ last_image=last_pixels,
+ )
+
+ assert latents is rollout_noise
+ assert condition.shape == (1, 7, 2, 2, 2)
+ assert adapter.vae.posterior.mode_calls == 1
+ assert adapter.vae.posterior.generators == []
+
+
+def test_wan_v2_condition_cache_keeps_ordered_pixels_through_prepare(
+ tmp_path: Path,
+) -> None:
+ Image.new("RGB", (4, 4), color="red").save(tmp_path / "first.png")
+ Image.new("RGB", (4, 4), color="blue").save(tmp_path / "last.png")
+ record = normalize_v2_record(
+ {
+ "schema_version": 2,
+ "input": {
+ "prompt": "first and last",
+ "media": [
+ {"type": "image", "path": "first.png"},
+ {"type": "image", "path": "last.png"},
+ ],
+ },
+ "supervision": {
+ "type": "demonstration",
+ "target": {"media": [{"type": "video", "path": "target.mp4"}]},
+ },
+ },
+ dataset_dir=tmp_path,
+ )
+ cache = build_offline_condition_cache(
+ [record],
+ source_name="wan-i2v",
+ dataset_dir=tmp_path,
+ cache_dir=tmp_path / "cache",
+ preprocess_func=_WanPreprocessHarness(with_clip=True).preprocess_func,
+ preprocessing_batch_size=1,
+ force_reprocess=True,
+ )
+ cache_row = dict(cache[0])
+ cache_row.pop(OFFLINE_CONDITION_ID_COLUMN)
+
+ condition = _collate_condition_mappings([cache_row])
+ assert len(condition["images"]) == 1
+ assert len(condition["images"][0]) == 2
+ assert condition["condition_images"].shape == (1, 2, 3, 16, 16)
+ torch.testing.assert_close(
+ condition["condition_images"][0, :, :, 0, 0],
+ torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]),
+ )
+ assert condition["image_embeds"].shape == (1, 2, 3, 5)
+ prepared = WanI2VConditionStatePreparer(_Adapter()).prepare_condition_state(condition)
+
+ assert "images" not in prepared.condition
+ assert prepared.forward_context["latent_condition"].shape == (1, 7, 2, 2, 2)
+ assert prepared.forward_context["image_embeds"].shape == (2, 3, 5)
+
+
+def test_wan_i2v_two_sample_first_last_embeds_round_trip_through_replay_stack() -> None:
+ packed = torch.arange(4 * 3 * 5, dtype=torch.float32).view(4, 3, 5)
+ per_sample = split_wan_image_embeds(packed, (2, 2))
+ samples = [
+ WanI2VSample(
+ image_embeds=per_sample[index],
+ latent_condition=torch.zeros(3, 2, 2, 2),
+ )
+ for index in range(2)
+ ]
+
+ replay_batch = WanI2VSample.stack(samples)
+ assert replay_batch["image_embeds"].shape == (2, 2, 3, 5)
+ restored = normalize_wan_image_embeds(replay_batch["image_embeds"], batch_size=2)
+
+ torch.testing.assert_close(restored, packed)
diff --git a/tests/models/trajectory/test_base_adapter_trajectory.py b/tests/models/trajectory/test_base_adapter_trajectory.py
index 72eab6c40..6d4360161 100644
--- a/tests/models/trajectory/test_base_adapter_trajectory.py
+++ b/tests/models/trajectory/test_base_adapter_trajectory.py
@@ -489,6 +489,49 @@ def test_reduce_latent_values_uses_global_element_weighting() -> None:
)
+def test_flow_matching_objective_reduction_defaults_to_global_weighting() -> None:
+ adapter = _structured_adapter()
+ values = {
+ "video": torch.tensor([[1.0, 3.0], [2.0, 4.0]]),
+ "audio": torch.tensor([[10.0], [20.0]]),
+ }
+
+ assert torch.equal(
+ adapter.reduce_flow_matching_objective_values(values),
+ adapter.reduce_latent_values(values),
+ )
+
+
+class FlowObjectiveReducerFake(StructuredAdapterFake):
+ """Adapter summing independently normalized modality objectives."""
+
+ def _reduce_flow_matching_objective_values(
+ self,
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: Optional[LatentState] = None,
+ ) -> torch.Tensor:
+ reduced = self.reduce_component_latent_values(values, state=state)
+ return reduced["video"] + reduced["audio"]
+
+
+def test_flow_matching_objective_reduction_can_preserve_online_reducer() -> None:
+ adapter = object.__new__(FlowObjectiveReducerFake)
+ values = {
+ "video": torch.tensor([[1.0, 3.0], [2.0, 4.0]]),
+ "audio": torch.tensor([[10.0], [20.0]]),
+ }
+
+ assert torch.equal(
+ adapter.reduce_flow_matching_objective_values(values),
+ torch.tensor([12.0, 23.0]),
+ )
+ assert torch.equal(
+ adapter.reduce_latent_values(values),
+ torch.tensor([14.0 / 3.0, 26.0 / 3.0]),
+ )
+
+
def test_reduce_latent_values_preserves_single_component_scalar_scale() -> None:
values = torch.tensor([2.0, 3.0])
diff --git a/tests/trainers/test_offline_batch_primitives.py b/tests/trainers/test_offline_batch_primitives.py
index dbc82d9fb..18c3d594c 100644
--- a/tests/trainers/test_offline_batch_primitives.py
+++ b/tests/trainers/test_offline_batch_primitives.py
@@ -19,8 +19,10 @@
import torch
from flow_factory.contracts import NON_MODEL_CONDITION_KEYS
+from flow_factory.models.condition_state import PreparedConditionState
from flow_factory.trainers.common.offline_batch import (
bind_output_forward_context,
+ bind_prepared_condition_output,
move_condition_to_device,
)
@@ -86,6 +88,30 @@ def test_bind_output_context_preserves_input_ownership_without_mutation() -> Non
assert tuple(context) == ("img_ids",)
+def test_bind_prepared_condition_preserves_input_and_output_ownership() -> None:
+ prepared = PreparedConditionState(
+ condition={"prompt_embeds": torch.ones(1, 2)},
+ forward_context={"condition_prefix": torch.zeros(1, 3)},
+ output_context={"codec_only": torch.ones(1)},
+ )
+
+ bound = bind_prepared_condition_output(prepared, {"output_ids": torch.zeros(4, 3)})
+
+ assert tuple(bound) == ("prompt_embeds", "condition_prefix", "output_ids")
+ assert "codec_only" not in bound
+
+
+def test_bind_prepared_condition_rejects_output_collision() -> None:
+ prepared = PreparedConditionState(
+ condition={"prompt_embeds": torch.ones(1, 2)},
+ forward_context={"condition_prefix": torch.zeros(1, 3)},
+ output_context={},
+ )
+
+ with pytest.raises(ValueError, match=r"collides.*condition_prefix"):
+ bind_prepared_condition_output(prepared, {"condition_prefix": torch.ones(1, 3)})
+
+
def test_bind_output_context_rejects_ambiguous_key_ownership() -> None:
with pytest.raises(ValueError, match=r"collides.*\('geometry',\)"):
bind_output_forward_context({"geometry": 1}, {"geometry": 2})
diff --git a/tests/trainers/test_offline_flow_matching.py b/tests/trainers/test_offline_flow_matching.py
index 8f24a0f36..123564a9e 100644
--- a/tests/trainers/test_offline_flow_matching.py
+++ b/tests/trainers/test_offline_flow_matching.py
@@ -245,7 +245,7 @@ def test_flow_matching_loss_computes_fp32_errors_before_adapter_reduction() -> N
received: dict[str, Any] = {}
class Adapter:
- def reduce_latent_values(self, values: Any, *, state: Any):
+ def reduce_flow_matching_objective_values(self, values: Any, *, state: Any):
received["values"] = values
received["state"] = state
total = torch.cat([value.flatten(1) for value in values.values()], dim=1)
diff --git a/tests/trainers/test_offline_trainers.py b/tests/trainers/test_offline_trainers.py
index 547ec44b8..a91fa99e3 100644
--- a/tests/trainers/test_offline_trainers.py
+++ b/tests/trainers/test_offline_trainers.py
@@ -30,6 +30,7 @@
PreferenceOutputBatch,
)
from flow_factory.data_utils.schema import NormalizedModelInput
+from flow_factory.models.condition_state import PreparedConditionState
from flow_factory.models.output_state import (
EncodedOutputState,
GeometrySignature,
@@ -108,6 +109,8 @@ def __init__(self) -> None:
self.pipeline_io_contract = object()
self.train_calls = 0
self.encode_calls: list[str] = []
+ self.prepare_calls = 0
+ self.prepared_condition_ids: list[int] = []
self.forward_events: list[tuple[float, bool, bool]] = []
self.forward_override_events: list[tuple[float, bool, dict[str, float]]] = []
self.drawn_noise: list[LatentState] = []
@@ -119,13 +122,23 @@ def train(self, mode: bool = True) -> None:
assert mode is True
self.train_calls += 1
+ def prepare_condition_state(
+ self,
+ condition: Mapping[str, Any],
+ generator: torch.Generator | None = None,
+ ) -> PreparedConditionState:
+ del generator
+ self.prepare_calls += 1
+ return PreparedConditionState.identity(condition)
+
def encode_output_state(
self,
media_batch: tuple[tuple[DecodedMedia, ...], ...],
- condition: Mapping[str, Any],
+ condition: Mapping[str, Any] | PreparedConditionState,
generator: torch.Generator | None = None,
) -> EncodedOutputState:
- del condition, generator
+ self.prepared_condition_ids.append(id(condition))
+ del generator
arm = str(media_batch[0][0].payload)
self.encode_calls.append(arm)
arm_value = 1.0 if arm == "rejected" else 0.0
@@ -255,6 +268,14 @@ def reduce_latent_values(
del state
return values["latent"].flatten(1).mean(dim=1)
+ @staticmethod
+ def reduce_flow_matching_objective_values(
+ values: Mapping[str, torch.Tensor],
+ *,
+ state: LatentState,
+ ) -> torch.Tensor:
+ return _Adapter.reduce_latent_values(values, state=state)
+
def _media(arm: str, batch_size: int = 2) -> tuple[tuple[DecodedMedia, ...], ...]:
return tuple(
@@ -355,6 +376,7 @@ def test_offline_trainers_build_unprepared_distributed_loaders(
trainer.adapter = SimpleNamespace(
preprocess_func=object(),
pipeline_io_contract=object(),
+ effective_pipeline_io_contract=object(),
)
sentinel = object()
received: dict[str, Any] = {}
@@ -374,7 +396,7 @@ def fake_builder(**kwargs: Any) -> Any:
"accelerator": trainer.accelerator,
"preprocess_func": trainer.adapter.preprocess_func,
"supervision_type": supervision_type,
- "pipeline_io_contract": trainer.adapter.pipeline_io_contract,
+ "pipeline_io_contract": trainer.adapter.effective_pipeline_io_contract,
}
assert trainer.accelerator.prepare_calls == 0
@@ -395,6 +417,7 @@ def test_sft_reencodes_targets_and_preserves_optimizer_cadence(
assert trainer.step == 1
assert adapter.train_calls == 2
+ assert adapter.prepare_calls == 2
assert adapter.encode_calls == ["target", "target"]
assert len(trainer.accelerator.backward_losses) == 2
assert len(trainer.accelerator.accumulate_roots) == 2
@@ -443,6 +466,8 @@ def test_offline_dpo_shares_schedule_noise_and_reference_scope(
assert trainer.step == 1
assert adapter.encode_calls == ["chosen", "rejected"]
+ assert adapter.prepare_calls == 1
+ assert adapter.prepared_condition_ids[0] == adapter.prepared_condition_ids[1]
assert len(adapter.drawn_noise) == 2
assert len(adapter.reused_noise) == 4
assert adapter.reused_noise[0] is adapter.drawn_noise[0]
diff --git a/tests/trainers/test_runtime_identity.py b/tests/trainers/test_runtime_identity.py
index 3f8cbb635..7350e667a 100644
--- a/tests/trainers/test_runtime_identity.py
+++ b/tests/trainers/test_runtime_identity.py
@@ -24,6 +24,7 @@
from accelerate.utils import DistributedType
from torch.utils.data import ConcatDataset, DataLoader, DistributedSampler
+from flow_factory.contracts import NegativePromptPolicy
from flow_factory.contracts.execution import OFFLINE_EXECUTION_CONTRACT
from flow_factory.data_utils.dataset import GeneralDataset
from flow_factory.data_utils.multi_source import (
@@ -36,6 +37,7 @@
AdamWOptimizerArguments,
MultiOptimizerArguments,
)
+from flow_factory.models.pipeline_contracts import image_output_contract
from flow_factory.trainers.common.runtime_identity import (
build_trainer_runtime_identity,
)
@@ -201,10 +203,16 @@ def __init__(
log_every: int = 10,
max_grad_norm: float = 1.0,
update_frequency: int = 1,
+ accepts_image_input: bool = False,
dataloader: DataLoader | None = None,
) -> None:
parameter = torch.nn.Parameter(torch.zeros(width, width))
self.adapter = _Adapter()
+ self.adapter.effective_pipeline_io_contract = image_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ input_image_min_count=0 if accepts_image_input else None,
+ input_image_max_count=1 if accepts_image_input else None,
+ )
self.adapter.component_variant_registry = _Registry(
{"base": (_Record("transformer", "weight", parameter),)}
)
@@ -320,6 +328,15 @@ def test_parameter_and_optimizer_schema_changes_have_independent_digests() -> No
assert changed_optimizer["optimizer_schema_digest"] != baseline["optimizer_schema_digest"]
+def test_effective_pipeline_contract_changes_execution_identity() -> None:
+ """Checkpoint-realized input semantics are exact-resume boundaries."""
+ baseline = build_trainer_runtime_identity(_Trainer(accepts_image_input=False))
+ changed = build_trainer_runtime_identity(_Trainer(accepts_image_input=True))
+
+ assert changed["execution_contract_digest"] != baseline["execution_contract_digest"]
+ assert changed["data_contract_digest"] == baseline["data_contract_digest"]
+
+
@pytest.mark.parametrize(
("field", "changed_value"),
(("max_grad_norm", 0.5), ("update_frequency", 3)),
From 850f61dd746cfd4926628ee3918f26643b01c932 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 10:07:42 +0800
Subject: [PATCH 32/76] feat(dataset): add public offline smoke builders
---
.agents/knowledge/topics/fix_patterns.md | 8 +
.gitignore | 4 +
README.md | 2 +
dataset/offline_smoke/README.md | 56 ++
dataset/offline_smoke/SOURCES.md | 32 +
dataset/offline_smoke/__init__.py | 45 ++
dataset/offline_smoke/build_mini.py | 666 +++++++++++++++++++
dataset/offline_smoke/datasets.lock.json | 15 +
dataset/offline_smoke/prepare.py | 319 +++++++++
dataset/offline_smoke/profiles.py | 317 +++++++++
dataset/offline_smoke/publish.py | 225 +++++++
dataset/offline_smoke/validate.py | 233 +++++++
guidance/datasets.md | 35 +
guidance/gpu_validation.md | 27 +-
tests/dataset/test_offline_smoke_build.py | 243 +++++++
tests/dataset/test_offline_smoke_prepare.py | 260 ++++++++
tests/dataset/test_offline_smoke_profiles.py | 144 ++++
tests/dataset/test_offline_smoke_publish.py | 98 +++
18 files changed, 2728 insertions(+), 1 deletion(-)
create mode 100644 dataset/offline_smoke/README.md
create mode 100644 dataset/offline_smoke/SOURCES.md
create mode 100644 dataset/offline_smoke/__init__.py
create mode 100644 dataset/offline_smoke/build_mini.py
create mode 100644 dataset/offline_smoke/datasets.lock.json
create mode 100644 dataset/offline_smoke/prepare.py
create mode 100644 dataset/offline_smoke/profiles.py
create mode 100644 dataset/offline_smoke/publish.py
create mode 100644 dataset/offline_smoke/validate.py
create mode 100644 tests/dataset/test_offline_smoke_build.py
create mode 100644 tests/dataset/test_offline_smoke_prepare.py
create mode 100644 tests/dataset/test_offline_smoke_profiles.py
create mode 100644 tests/dataset/test_offline_smoke_publish.py
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 401c79356..2223bed35 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -246,6 +246,14 @@ Based on the fix type, write the fix entry to the appropriate document:
numerically lossy or RNG-consuming transition work that is outside their requested output.
- **Related Constraint**: #7
+### Documented dataset tools must use their package invocation mode
+- **Date**: 2026-08-30
+- **Symptom**: Running the documented `python dataset/offline_smoke/prepare.py ...` command failed before argument parsing with `attempted relative import with no known parent package`.
+- **Root Cause**: The package uses relative imports, while the documentation incorrectly advertised direct file execution instead of Python's module mode.
+- **Fix**: All offline-smoke commands now use `python -m dataset.offline_smoke.` with unconditional package imports, and a subprocess regression executes the documented form.
+- **Lesson**: A checked-in CLI example is part of the public interface; standardize on one package-aware invocation and test that exact process rather than adding conditional import fallbacks.
+- **Related Constraint**: N/A
+
### Exact resume must lock the checkpoint-realized pipeline contract
- **Date**: 2026-08-29
- **Symptom**: Exact resume could accept a checkpoint after an in-place model configuration change
diff --git a/.gitignore b/.gitignore
index 2f84609c7..854e74f24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -109,6 +109,10 @@ latest_checkpointed_iteration.txt
# Dataset assets (keep metadata, ignore media)
# ====================
+# Reconstructed/publication staging for the SFT and offline-DPO smoke datasets
+dataset/_prepared_offline_smoke/
+dataset/offline_smoke/_staging/
+
# gitattributes in dataset
dataset/**/.gitattributes
diff --git a/README.md b/README.md
index 8c0a23ca1..6e6ca2788 100644
--- a/README.md
+++ b/README.md
@@ -244,6 +244,8 @@ encoded on the fly; their VAE latents are never stored in the preprocessing cach
epoch is one complete dataloader traversal sharded by PyTorch's official `DistributedSampler`. See the
[dataset guide](guidance/datasets.md#offline-v2-records) for the full schema and cadence rules, and
the [GPU validation plan](guidance/gpu_validation.md) for the 120-job model/backend/algorithm matrix.
+The [offline smoke builder](dataset/offline_smoke/README.md) reconstructs independent SFT and
+offline-DPO mini datasets for every currently implemented image, video, and audio-video profile.
## Text-to-Image & Text-to-Video
diff --git a/dataset/offline_smoke/README.md b/dataset/offline_smoke/README.md
new file mode 100644
index 000000000..09c8533eb
--- /dev/null
+++ b/dataset/offline_smoke/README.md
@@ -0,0 +1,56 @@
+# Offline smoke datasets
+
+This directory contains the reproducible build, preparation, validation, and
+publication tooling for two independent public fixtures:
+
+- [Jayce-Ping/Flow-Factory-SFT-Smoke](https://huggingface.co/datasets/Jayce-Ping/Flow-Factory-SFT-Smoke)
+- [Jayce-Ping/Flow-Factory-Offline-DPO-Smoke](https://huggingface.co/datasets/Jayce-Ping/Flow-Factory-Offline-DPO-Smoke)
+
+The repositories are intentionally small correctness fixtures. They are not
+quality-training corpora. All media are deterministic procedural assets released
+under CC0-1.0; the builder does not download third-party data or create VAE
+latent caches.
+
+See [SOURCES.md](SOURCES.md) for the DiffSynth, DyRef, image, video, and audio-video
+datasets reviewed for schema compatibility and the reasons they are not silently
+mirrored into these public fixtures.
+
+## Build staging repositories
+
+Install the project dependencies, including Pillow, NumPy, and PyAV, then run:
+
+```bash
+python -m dataset.offline_smoke.build_mini \
+ --staging-root dataset/offline_smoke/_staging
+```
+
+The command creates two self-contained trees. It refuses to overwrite an
+existing staging tree unless `--replace` is supplied. A fixed seed and fixed
+dependency versions produce the same logical records and media.
+
+Each tree contains:
+
+```text
+README.md
+LICENSE
+dataset_manifest.json
+provenance.jsonl
+media/
+profiles//train.jsonl
+```
+
+Every runtime alias has 32 strict V2 records, enough for two local batches on up
+to 16 ranks with per-device batch size one. Media paths in each JSONL are
+relative to its profile directory (`../../media/...`). The aliases cover the ten
+main GPU modes plus the supplemental `image-i2i` contract gate.
+
+Video DPO corruptions preserve the decoded first and last frames. Audio-video
+candidates follow the declared exact output order `[video, audio]`. The generic
+candidate projection follows `profiles.py` rather than assuming that every
+multi-component output is AV, so a future pure-audio or other output contract can
+be added without changing the public V2 record model.
+
+Preparation performs V2, task-profile contract, path, and media validation; see
+`python -m dataset.offline_smoke.prepare --help`. Publication is a separate
+external-state operation and requires the explicit confirmation flag shown by
+`python -m dataset.offline_smoke.publish --help`.
diff --git a/dataset/offline_smoke/SOURCES.md b/dataset/offline_smoke/SOURCES.md
new file mode 100644
index 000000000..1d3acf517
--- /dev/null
+++ b/dataset/offline_smoke/SOURCES.md
@@ -0,0 +1,32 @@
+# Dataset source review
+
+The published smoke repositories use only deterministic procedural media generated by
+`build_mini.py`. This keeps the fixtures self-contained, redistributable, and exact enough to
+exercise endpoint and audio/video clock contracts. They are correctness fixtures, not substitutes
+for semantic-quality training data.
+
+The following public datasets informed the schema and remain candidates for optional, separately
+licensed semantic demos:
+
+| Source | Relevant profiles | License/access decision |
+|---|---|---|
+| [ShareGPT-4o-Image](https://huggingface.co/datasets/FreedomIntelligence/ShareGPT-4o-Image) | T2I and I2I SFT | Apache-2.0. A future mini must preserve both input and output images; the legacy Flow-Factory mini retained only the output. |
+| [Open Image Preferences](https://huggingface.co/datasets/data-is-better-together/open-image-preferences-v1-binarized) | T2I offline DPO | Apache-2.0 human preference pairs. Suitable for a separately attributed semantic subset. |
+| [MACRO](https://huggingface.co/datasets/Azily/Macro-Dataset) | Ordered multi-image-to-image SFT | CC-BY-4.0. Preferred over non-commercial multi-reference alternatives when attribution is retained. |
+| [DyRef](https://github.com/Weistrass/DyRef) and [OmniRef training data](https://huggingface.co/datasets/Eason0438/OmniRef-training) | Ordered multi-image SFT and online RL | The pipeline is SFT followed by online RL, not offline DPO. The Hub dataset is tagged Apache-2.0, but per-asset provenance is incomplete, so the smoke repositories do not mirror it. |
+| [T2V Ranking Human Preferences](https://huggingface.co/datasets/datapointai/text-2-video-ranking-human-preferences) | T2V offline DPO | CC-BY-4.0 and gated. A local opt-in importer may derive best/worst pairs after the user accepts upstream access; the public smoke data must not bypass the gate. |
+| [I2V Human Preferences](https://huggingface.co/datasets/datapointai/image-2-video-human-preferences-large) | I2V offline DPO | CC-BY-4.0 and gated. Its shared reference image matches the I2V preference contract, but it is not mirrored here. |
+| [VA-Judger-Bench](https://huggingface.co/datasets/ShareLab-SII/VA-Judger-Bench) | T2AV preference structure | MIT-tagged paired audio/video benchmark. Kept as a schema reference rather than republishing benchmark media as training data. |
+| [DiffSynth-Studio examples](https://github.com/modelscope/DiffSynth-Studio/tree/main/examples) | Wan, LTX2, and MiniMax-H3 SFT field flow | Apache-2.0 code. Example media lack per-file provenance, so only the field and media-clock conventions were reused. |
+
+The following sources are intentionally excluded from the public smoke repositories:
+
+- SafeSora and ONE-Lab MultiRef: non-commercial dataset terms.
+- Pico-Banana preference data: non-commercial, no-derivatives terms.
+- VideoDPO release media: no sufficiently clear data redistribution license was found.
+- MiniMax-H3-generated media: the model output license is not suitable for a model-neutral,
+ cross-family training fixture.
+
+Any future semantic subset must pin an immutable upstream revision and retain source row IDs,
+license, attribution, transforms, and per-asset SHA-256 values. Mixed-license assets must not be
+relicensed as CC0 or Apache-2.0.
diff --git a/dataset/offline_smoke/__init__.py b/dataset/offline_smoke/__init__.py
new file mode 100644
index 000000000..c606120cc
--- /dev/null
+++ b/dataset/offline_smoke/__init__.py
@@ -0,0 +1,45 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Build declarations for Flow-Factory's public offline smoke datasets."""
+
+from .profiles import (
+ CANONICAL_PROFILES,
+ DATASET_REPO_IDS,
+ GPU_ALIAS_TO_PROFILE,
+ MAIN_GPU_ALIASES,
+ OFFLINE_DPO_REPO_ID,
+ SFT_REPO_ID,
+ SUPPLEMENTAL_GPU_ALIASES,
+ GPUSmokeCase,
+ OfflineSmokeProfile,
+ SmokeGeometry,
+ get_profile,
+ output_media_types,
+)
+
+__all__ = [
+ "CANONICAL_PROFILES",
+ "DATASET_REPO_IDS",
+ "GPUSmokeCase",
+ "GPU_ALIAS_TO_PROFILE",
+ "MAIN_GPU_ALIASES",
+ "OFFLINE_DPO_REPO_ID",
+ "OfflineSmokeProfile",
+ "SFT_REPO_ID",
+ "SUPPLEMENTAL_GPU_ALIASES",
+ "SmokeGeometry",
+ "get_profile",
+ "output_media_types",
+]
diff --git a/dataset/offline_smoke/build_mini.py b/dataset/offline_smoke/build_mini.py
new file mode 100644
index 000000000..6c5378f39
--- /dev/null
+++ b/dataset/offline_smoke/build_mini.py
@@ -0,0 +1,666 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Build the two deterministic, procedural offline-smoke HF staging trees."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import math
+import os
+import shutil
+import tempfile
+import wave
+from dataclasses import dataclass
+from fractions import Fraction
+from pathlib import Path
+from typing import Any, Iterable, Literal, Mapping, Sequence
+
+import av
+import numpy as np
+from PIL import Image, ImageDraw
+
+from .profiles import (
+ GPU_ALIAS_TO_PROFILE,
+ MAIN_GPU_ALIASES,
+ OFFLINE_DPO_REPO_ID,
+ SFT_REPO_ID,
+)
+
+RUNTIME_ALIASES = MAIN_GPU_ALIASES + ("image-i2i",)
+RECORDS_PER_ALIAS = 32
+DEFAULT_SEED = 20260830
+FPS = 24
+CC0 = "CC0-1.0"
+COLORS = (
+ ("coral", (224, 83, 74)),
+ ("amber", (234, 166, 52)),
+ ("teal", (42, 157, 143)),
+ ("blue", (65, 105, 225)),
+ ("violet", (139, 92, 246)),
+)
+SHAPES = ("circle", "square", "triangle", "diamond", "star")
+
+
+@dataclass(frozen=True, slots=True)
+class _Asset:
+ """One repo-root-relative generated media asset."""
+
+ type: Literal["image", "video", "audio"]
+ path: str
+ fps: int | None = None
+ sample_rate: int | None = None
+
+
+class _Writer:
+ """Write one self-contained repository and its provenance index."""
+
+ def __init__(
+ self,
+ root: Path,
+ repo_id: str,
+ supervision: Literal["demonstration", "preference"],
+ seed: int,
+ records_per_alias: int,
+ ) -> None:
+ self.root = root
+ self.repo_id = repo_id
+ self.supervision = supervision
+ self.seed = seed
+ self.records_per_alias = records_per_alias
+ self.provenance: list[dict[str, Any]] = []
+ self.script_sha = _sha256(Path(__file__))
+
+ def image(self, relative: str, value: Image.Image, seed: int) -> _Asset:
+ path = self._new_path(relative)
+ value.convert("RGB").save(path, format="PNG", compress_level=9)
+ self._record(relative, "image", seed, width=value.width, height=value.height)
+ return _Asset("image", relative)
+
+ def video(self, relative: str, frames: Sequence[np.ndarray], seed: int) -> _Asset:
+ path = self._new_path(relative)
+ height, width, channels = frames[0].shape
+ if channels != 3 or any(frame.shape != frames[0].shape for frame in frames):
+ raise ValueError("video frames must share one RGB geometry")
+ with av.open(str(path), "w", format="avi") as container:
+ stream = container.add_stream("ffv1", rate=Fraction(FPS, 1))
+ stream.width, stream.height, stream.pix_fmt = width, height, "bgr0"
+ for index, array in enumerate(frames):
+ frame = av.VideoFrame.from_ndarray(array, format="rgb24")
+ frame.pts = index
+ for packet in stream.encode(frame):
+ container.mux(packet)
+ for packet in stream.encode():
+ container.mux(packet)
+ decoded = _decode(path)
+ if len(decoded) != len(frames) or decoded[0].shape != frames[0].shape:
+ raise RuntimeError(f"generated video failed decode probe: {relative}")
+ self._record(
+ relative,
+ "video",
+ seed,
+ width=width,
+ height=height,
+ num_frames=len(frames),
+ fps=FPS,
+ codec="ffv1",
+ )
+ return _Asset("video", relative, fps=FPS)
+
+ def audio(self, relative: str, samples: np.ndarray, rate: int, seed: int) -> _Asset:
+ path = self._new_path(relative)
+ with wave.open(str(path), "wb") as output:
+ output.setparams((2, 2, rate, samples.shape[0], "NONE", "not compressed"))
+ output.writeframes(samples.astype(" None:
+ for alias in RUNTIME_ALIASES:
+ alias_dir = self.root / "profiles" / alias
+ alias_dir.mkdir(parents=True)
+ _write_jsonl(alias_dir / "train.jsonl", records[alias])
+ _write_jsonl(
+ self.root / "provenance.jsonl", sorted(self.provenance, key=lambda x: x["path"])
+ )
+ manifest = {
+ "schema_version": 1,
+ "repository_id": self.repo_id,
+ "flow_factory_schema_version": 2,
+ "supervision_type": self.supervision,
+ "records_per_alias": self.records_per_alias,
+ "runtime_aliases": list(RUNTIME_ALIASES),
+ "license": CC0,
+ "condition_endpoint_check": {
+ "metric": "decoded_rgb_max_absolute_difference",
+ "tolerance": 0,
+ },
+ "generator": {
+ "script": "dataset/offline_smoke/build_mini.py",
+ "script_sha256": self.script_sha,
+ "seed": self.seed,
+ },
+ }
+ _write_json(self.root / "dataset_manifest.json", manifest)
+ (self.root / "README.md").write_text(_card(self), encoding="utf-8")
+ (self.root / "LICENSE").write_text(_cc0_notice(), encoding="utf-8")
+
+ def metadata(self, alias: str, index: int, seed: int) -> dict[str, Any]:
+ value: dict[str, Any] = {
+ "sample_id": f"{alias}-{index:04d}",
+ "profile": GPU_ALIAS_TO_PROFILE[alias].name,
+ "gpu_alias": alias,
+ "usage_tier": "smoke_only",
+ "source": {
+ "origin": "flow_factory_procedural",
+ "license": CC0,
+ "generator_seed": seed,
+ "generator_script_sha256": self.script_sha,
+ },
+ }
+ if self.supervision == "preference":
+ value.update(
+ preference_origin="deterministic_corruption",
+ semantic_preference_claim=False,
+ )
+ return value
+
+ def _new_path(self, relative: str) -> Path:
+ path = self.root / relative
+ if not path.resolve().is_relative_to(self.root.resolve()) or path.exists():
+ raise ValueError(f"invalid or duplicate asset path: {relative!r}")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return path
+
+ def _record(self, relative: str, media_type: str, seed: int, **media: Any) -> None:
+ path = self.root / relative
+ self.provenance.append(
+ {
+ "path": relative,
+ "type": media_type,
+ "sha256": _sha256(path),
+ "size_bytes": path.stat().st_size,
+ "origin": "flow_factory_procedural",
+ "license": CC0,
+ "generator_seed": seed,
+ "media": media,
+ }
+ )
+
+
+def build(
+ staging_root: Path,
+ seed: int,
+ replace: bool,
+ records_per_alias: int = RECORDS_PER_ALIAS,
+) -> tuple[Path, Path]:
+ """Build independent SFT and offline-DPO staging trees atomically.
+
+ Args:
+ staging_root: Directory that receives the two repository trees.
+ seed: Base seed for deterministic procedural records.
+ replace: Whether to replace the two exact destination directories.
+ records_per_alias: Number of records generated for every runtime alias.
+
+ Returns:
+ Paths to the SFT and offline-DPO staging trees, in that order.
+
+ Raises:
+ ValueError: If ``records_per_alias`` is not positive.
+ FileExistsError: If a destination exists and ``replace`` is false.
+ """
+ if records_per_alias < 1:
+ raise ValueError("records_per_alias must be positive")
+ staging_root.mkdir(parents=True, exist_ok=True)
+ destinations = tuple(
+ staging_root / repo_id.rsplit("/", 1)[1] for repo_id in (SFT_REPO_ID, OFFLINE_DPO_REPO_ID)
+ )
+ if not replace and any(path.exists() for path in destinations):
+ raise FileExistsError("staging repo exists; pass --replace for the two exact targets")
+ temporary = Path(tempfile.mkdtemp(prefix=".build-mini-", dir=staging_root))
+ try:
+ for repo_id, supervision, destination in zip(
+ (SFT_REPO_ID, OFFLINE_DPO_REPO_ID),
+ ("demonstration", "preference"),
+ destinations,
+ ):
+ writer = _Writer(
+ temporary / destination.name,
+ repo_id,
+ supervision,
+ seed,
+ records_per_alias,
+ )
+ writer.root.mkdir()
+ records = {alias: [] for alias in RUNTIME_ALIASES}
+ for index in range(records_per_alias):
+ row_seed = _seed(seed, supervision, index)
+ pools = _assets(writer, index, row_seed)
+ for alias in RUNTIME_ALIASES:
+ records[alias].append(_record(writer, alias, index, row_seed, pools))
+ writer.finish(records)
+ if replace:
+ for destination in destinations:
+ if destination.exists():
+ shutil.rmtree(destination)
+ for destination in destinations:
+ os.replace(temporary / destination.name, destination)
+ finally:
+ shutil.rmtree(temporary, ignore_errors=True)
+ return destinations
+
+
+def _assets(writer: _Writer, index: int, seed: int) -> dict[str, dict[str, _Asset]]:
+ """Generate shared image, Wan, LTX, and H3 pools for one logical row."""
+ first = (SHAPES[index % len(SHAPES)], COLORS[index % len(COLORS)])
+ second = (SHAPES[(index + 2) % len(SHAPES)], COLORS[(index + 2) % len(COLORS)])
+ prefix = f"media/{index:04d}"
+ image_pool = {
+ "input": writer.image(f"{prefix}/image/input.png", _scene(first, "center"), seed),
+ "ref1": writer.image(f"{prefix}/image/ref1.png", _scene(first, "center"), seed),
+ "ref2": writer.image(f"{prefix}/image/ref2.png", _scene(second, "center"), seed),
+ "t2i_chosen": writer.image(f"{prefix}/image/t2i_chosen.png", _scene(first, "center"), seed),
+ "i2i_chosen": writer.image(
+ f"{prefix}/image/i2i_chosen.png", _scene(second, "upper_left"), seed
+ ),
+ "multi_chosen": writer.image(
+ f"{prefix}/image/multi_chosen.png", _composition(first, second, False), seed
+ ),
+ }
+ if writer.supervision == "preference":
+ image_pool.update(
+ t2i_rejected=writer.image(
+ f"{prefix}/image/t2i_rejected.png", _scene(second, "lower_right"), seed
+ ),
+ i2i_rejected=writer.image(
+ f"{prefix}/image/i2i_rejected.png", _scene(first, "lower_right"), seed
+ ),
+ multi_rejected=writer.image(
+ f"{prefix}/image/multi_rejected.png",
+ _composition(first, second, True),
+ seed,
+ ),
+ )
+ pools = {"image": image_pool}
+ for family, alias in (("wan", "wan-t2v"), ("ltx", "ltx2-t2av"), ("h3", "h3-t2va")):
+ geometry = _geometry(alias)
+ frames = _motion(geometry.width, geometry.height, geometry.num_frames, first, second)
+ pool: dict[str, _Asset] = {
+ "chosen_video": writer.video(f"{prefix}/{family}/chosen.avi", frames, seed)
+ }
+ decoded = _decode(writer.root / pool["chosen_video"].path)
+ pool["first"] = writer.image(
+ f"{prefix}/{family}/first.png", Image.fromarray(decoded[0]), seed
+ )
+ pool["last"] = writer.image(
+ f"{prefix}/{family}/last.png", Image.fromarray(decoded[-1]), seed
+ )
+ if geometry.sample_rate is not None:
+ samples = _audio(geometry.sample_rate, geometry.num_frames / geometry.frame_rate, index)
+ pool["chosen_audio"] = writer.audio(
+ f"{prefix}/{family}/chosen.wav", samples, geometry.sample_rate, seed
+ )
+ if writer.supervision == "preference":
+ rejected_frames = _corrupt_frames(frames, second[1][1])
+ pool["rejected_video"] = writer.video(
+ f"{prefix}/{family}/rejected.avi", rejected_frames, seed
+ )
+ rejected_decoded = _decode(writer.root / pool["rejected_video"].path)
+ if not (
+ np.array_equal(decoded[0], rejected_decoded[0])
+ and np.array_equal(decoded[-1], rejected_decoded[-1])
+ ):
+ raise RuntimeError(f"{family} DPO corruption changed a decoded endpoint")
+ if geometry.sample_rate is not None:
+ pool["rejected_audio"] = writer.audio(
+ f"{prefix}/{family}/rejected.wav",
+ _corrupt_audio(samples),
+ geometry.sample_rate,
+ seed,
+ )
+ pools[family] = pool
+ return pools
+
+
+def _record(
+ writer: _Writer,
+ alias: str,
+ index: int,
+ seed: int,
+ pools: Mapping[str, Mapping[str, _Asset]],
+) -> dict[str, Any]:
+ profile = GPU_ALIAS_TO_PROFILE[alias]
+ first = (SHAPES[index % len(SHAPES)], COLORS[index % len(COLORS)][0])
+ second = (SHAPES[(index + 2) % len(SHAPES)], COLORS[(index + 2) % len(COLORS)][0])
+ prompt = f"A {first[1]} {first[0]} moves left to right beside a {second[1]} {second[0]}."
+ family = _family(alias)
+ input_media: list[dict[str, Any]] = []
+ if alias == "image-i2i":
+ prompt = f"Move the {first[1]} {first[0]} to the upper left and recolor it {second[1]}."
+ input_media = [_media(pools["image"]["input"])]
+ elif alias == "bagel-mri2i":
+ prompt = "Place reference 1 on the left and reference 2 on the right."
+ input_media = [_media(pools["image"]["ref1"]), _media(pools["image"]["ref2"])]
+ elif alias in ("wan-i2v-first", "ltx2-i2av"):
+ input_media = [_media(pools[family]["first"], slot="first_frame")]
+ elif alias == "wan-flf2v":
+ input_media = [
+ _media(pools[family]["first"], slot="first_frame"),
+ _media(pools[family]["last"], slot="last_frame"),
+ ]
+ elif alias == "h3-fl2va":
+ cases = (
+ [_media(pools[family]["first"], slot="first_frame")],
+ [_media(pools[family]["last"], slot="last_frame")],
+ [
+ _media(pools[family]["first"], slot="first_frame"),
+ _media(pools[family]["last"], slot="last_frame"),
+ ],
+ )
+ input_media = cases[index % len(cases)]
+ elif alias == "h3-ref2va":
+ refs = [
+ _media(pools["wan"]["first"]),
+ _media(pools["wan"]["chosen_video"]),
+ _media(pools["h3"]["chosen_audio"]),
+ ]
+ offset = index % len(refs)
+ input_media = refs[offset:] + refs[:offset]
+
+ output_types = tuple(item.type.value for item in profile.contract.output_media.items)
+ candidates = _candidate_assets(alias, family, pools)
+ chosen = _candidate(output_types, candidates, "chosen")
+ if writer.supervision == "demonstration":
+ supervision: dict[str, Any] = {"type": "demonstration", "target": chosen}
+ else:
+ supervision = {
+ "type": "preference",
+ "chosen": chosen,
+ "rejected": _candidate(output_types, candidates, "rejected"),
+ }
+ return {
+ "schema_version": 2,
+ "input": {"prompt": prompt, "media": input_media},
+ "supervision": supervision,
+ "metadata": writer.metadata(alias, index, seed),
+ }
+
+
+def _candidate_assets(
+ alias: str,
+ family: str,
+ pools: Mapping[str, Mapping[str, _Asset]],
+) -> Mapping[str, Mapping[str, _Asset]]:
+ if alias == "sd35-t2i":
+ names = ("t2i_chosen", "t2i_rejected")
+ elif alias == "image-i2i":
+ names = ("i2i_chosen", "i2i_rejected")
+ elif alias == "bagel-mri2i":
+ names = ("multi_chosen", "multi_rejected")
+ else:
+ pool = pools[family]
+ return {
+ "chosen": {
+ kind: pool[f"chosen_{kind}"]
+ for kind in ("video", "audio")
+ if f"chosen_{kind}" in pool
+ },
+ "rejected": {
+ kind: pool[f"rejected_{kind}"]
+ for kind in ("video", "audio")
+ if f"rejected_{kind}" in pool
+ },
+ }
+ pool = pools["image"]
+ return {
+ "chosen": {"image": pool[names[0]]},
+ "rejected": {"image": pool[names[1]]} if names[1] in pool else {},
+ }
+
+
+def _candidate(
+ output_types: Sequence[str],
+ candidates: Mapping[str, Mapping[str, _Asset]],
+ side: str,
+) -> dict[str, Any]:
+ """Project any declared output sequence without assuming AV-only candidates."""
+ available = candidates[side]
+ media = [_media(available[media_type]) for media_type in output_types]
+ if tuple(item["type"] for item in media) != tuple(output_types):
+ raise RuntimeError("candidate media order diverged from the profile contract")
+ return {"media": media}
+
+
+def _geometry(alias: str) -> Any:
+ profile = GPU_ALIAS_TO_PROFILE[alias]
+ return next(case.geometry for case in profile.gpu_cases if case.alias == alias)
+
+
+def _family(alias: str) -> str:
+ if alias.startswith("wan-"):
+ return "wan"
+ if alias.startswith("ltx2-"):
+ return "ltx"
+ if alias.startswith("h3-"):
+ return "h3"
+ return "image"
+
+
+def _media(asset: _Asset, slot: str | None = None) -> dict[str, Any]:
+ value: dict[str, Any] = {"type": asset.type, "path": f"../../{asset.path}"}
+ if asset.fps is not None:
+ value["fps"] = asset.fps
+ if asset.sample_rate is not None:
+ value["sample_rate"] = asset.sample_rate
+ if slot is not None:
+ value["slot"] = slot
+ return value
+
+
+def _scene(item: tuple[str, tuple[str, tuple[int, int, int]]], position: str) -> Image.Image:
+ shape, (_, color) = item
+ image = Image.new("RGB", (256, 256), (235, 239, 242))
+ centers = {"center": (128, 128), "upper_left": (80, 80), "lower_right": (176, 176)}
+ _draw_shape(ImageDraw.Draw(image), shape, centers[position], 42, color)
+ return image
+
+
+def _composition(
+ first: tuple[str, tuple[str, tuple[int, int, int]]],
+ second: tuple[str, tuple[str, tuple[int, int, int]]],
+ swap: bool,
+) -> Image.Image:
+ image = Image.new("RGB", (256, 256), (235, 239, 242))
+ positions = ((76, 128), (180, 128)) if not swap else ((180, 128), (76, 128))
+ draw = ImageDraw.Draw(image)
+ _draw_shape(draw, first[0], positions[0], 36, first[1][1])
+ _draw_shape(draw, second[0], positions[1], 36, second[1][1])
+ return image
+
+
+def _motion(
+ width: int,
+ height: int,
+ count: int,
+ first: tuple[str, tuple[str, tuple[int, int, int]]],
+ second: tuple[str, tuple[str, tuple[int, int, int]]],
+) -> list[np.ndarray]:
+ frames = []
+ radius = max(min(width, height) // 12, 4)
+ for index in range(count):
+ phase = index / max(count - 1, 1)
+ image = Image.new("RGB", (width, height), (232, 238, 242))
+ draw = ImageDraw.Draw(image)
+ x = round(radius * 2 + phase * (width - radius * 4))
+ y = round(height * (0.55 + 0.1 * math.sin(phase * math.tau)))
+ _draw_shape(draw, first[0], (x, y), radius, first[1][1])
+ _draw_shape(draw, second[0], (width * 3 // 4, height // 3), radius, second[1][1])
+ frames.append(np.asarray(image, dtype=np.uint8).copy())
+ return frames
+
+
+def _draw_shape(
+ draw: ImageDraw.ImageDraw,
+ shape: str,
+ center: tuple[int, int],
+ radius: int,
+ color: tuple[int, int, int],
+) -> None:
+ x, y = center
+ if shape == "circle":
+ draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=color)
+ elif shape == "square":
+ draw.rectangle((x - radius, y - radius, x + radius, y + radius), fill=color)
+ elif shape == "triangle":
+ draw.polygon(
+ ((x, y - radius), (x - radius, y + radius), (x + radius, y + radius)), fill=color
+ )
+ elif shape == "diamond":
+ draw.polygon(
+ ((x, y - radius), (x - radius, y), (x, y + radius), (x + radius, y)), fill=color
+ )
+ else:
+ points = []
+ for point in range(10):
+ angle = -math.pi / 2 + point * math.pi / 5
+ distance = radius if point % 2 == 0 else radius * 0.45
+ points.append(
+ (round(x + math.cos(angle) * distance), round(y + math.sin(angle) * distance))
+ )
+ draw.polygon(points, fill=color)
+
+
+def _audio(rate: int, duration: float, index: int) -> np.ndarray:
+ timeline = np.arange(round(rate * duration), dtype=np.float64) / rate
+ frequency = 180 + 17 * (index % 7)
+ phase = math.tau * (frequency * timeline + 20 * timeline**2)
+ envelope = np.sin(np.pi * timeline / duration) ** 0.5
+ stereo = np.stack((np.sin(phase), np.sin(phase + math.pi / 3)), axis=-1)
+ return np.rint(stereo * envelope[:, None] * 9000).astype(np.int16)
+
+
+def _corrupt_frames(frames: Sequence[np.ndarray], accent: tuple[int, int, int]) -> list[np.ndarray]:
+ output = [frames[0].copy()]
+ for index, frame in enumerate(reversed(frames[1:-1]), start=1):
+ changed = frame.copy()
+ width = changed.shape[1]
+ x = (index * 7) % max(width - 2, 1)
+ changed[:, x : x + 2] = accent
+ output.append(changed)
+ output.append(frames[-1].copy())
+ return output
+
+
+def _corrupt_audio(samples: np.ndarray) -> np.ndarray:
+ output = np.roll(samples, max(samples.shape[0] // 9, 1), axis=0).copy()
+ output[:, 1] *= -1
+ return output
+
+
+def _decode(path: Path) -> list[np.ndarray]:
+ with av.open(str(path)) as container:
+ frames = [frame.to_ndarray(format="rgb24") for frame in container.decode(video=0)]
+ if not frames:
+ raise RuntimeError(f"generated video decoded no frames: {path}")
+ return frames
+
+
+def _seed(base: int, supervision: str, index: int) -> int:
+ digest = hashlib.sha256(f"{base}:{supervision}:{index}".encode()).digest()
+ return int.from_bytes(digest[:8], "big")
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _write_json(path: Path, value: Mapping[str, Any]) -> None:
+ path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+def _write_jsonl(path: Path, values: Iterable[Mapping[str, Any]]) -> None:
+ with path.open("w", encoding="utf-8") as output:
+ for value in values:
+ output.write(json.dumps(value, separators=(",", ":"), sort_keys=True) + "\n")
+
+
+def _card(writer: _Writer) -> str:
+ return f"""---
+license: cc0-1.0
+pretty_name: Flow-Factory {writer.supervision} Smoke Fixtures
+---
+
+# {writer.repo_id}
+
+Deterministic procedural correctness fixtures for Flow-Factory's strict V2
+offline schema. Each runtime alias under `profiles/` has {writer.records_per_alias} records and resolves
+its `../../media/...` paths inside this repository. Joint outputs are ordered
+`[video, audio]`. Preference pairs are synthetic smoke-only corruptions, not
+human labels. See `dataset_manifest.json` and `provenance.jsonl` for identities.
+"""
+
+
+def _cc0_notice() -> str:
+ return """CC0 1.0 Universal
+
+To the extent possible under law, the authors waive copyright and related
+rights in these generated dataset assets.
+https://creativecommons.org/publicdomain/zero/1.0/legalcode
+"""
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Build both public dataset staging trees from command-line arguments.
+
+ Args:
+ argv: Optional argument sequence. Uses ``sys.argv`` when omitted.
+
+ Returns:
+ Process exit code zero after a successful build.
+ """
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--staging-root",
+ type=Path,
+ default=Path(__file__).resolve().parent / "_staging",
+ )
+ parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
+ parser.add_argument("--records-per-alias", type=int, default=RECORDS_PER_ALIAS)
+ parser.add_argument("--replace", action="store_true")
+ args = parser.parse_args(argv)
+ paths = build(
+ args.staging_root.expanduser().resolve(),
+ args.seed,
+ args.replace,
+ args.records_per_alias,
+ )
+ print(json.dumps({"sft": str(paths[0]), "offline_dpo": str(paths[1])}, sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dataset/offline_smoke/datasets.lock.json b/dataset/offline_smoke/datasets.lock.json
new file mode 100644
index 000000000..a1f3d4575
--- /dev/null
+++ b/dataset/offline_smoke/datasets.lock.json
@@ -0,0 +1,15 @@
+{
+ "schema_version": 1,
+ "datasets": {
+ "offline-dpo": {
+ "repo_id": "Jayce-Ping/Flow-Factory-Offline-DPO-Smoke",
+ "revision": "b6ec289238eefdd6c855b7fb8f87ccc80ee3040f",
+ "supervision_type": "preference"
+ },
+ "sft": {
+ "repo_id": "Jayce-Ping/Flow-Factory-SFT-Smoke",
+ "revision": "50e7f0e897ed2ce4160b14340fc8a982fe6c6919",
+ "supervision_type": "demonstration"
+ }
+ }
+}
diff --git a/dataset/offline_smoke/prepare.py b/dataset/offline_smoke/prepare.py
new file mode 100644
index 000000000..95dbfe060
--- /dev/null
+++ b/dataset/offline_smoke/prepare.py
@@ -0,0 +1,319 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Download and atomically materialize one pinned offline-smoke profile."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import shutil
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable, Dict, Literal, Mapping, Sequence
+
+from huggingface_hub import snapshot_download
+
+from flow_factory.data_utils.schema import DatasetRecordV2
+
+from .profiles import DATASET_REPO_IDS, GPU_ALIAS_TO_PROFILE
+from .validate import validate_dataset
+
+Algorithm = Literal["sft", "offline-dpo"]
+SnapshotDownload = Callable[..., str]
+DEFAULT_LOCK_PATH = Path(__file__).with_name("datasets.lock.json")
+DEFAULT_OUTPUT_ROOT = Path("dataset/_prepared_offline_smoke")
+PENDING_REVISION = "PENDING_INITIAL_UPLOAD"
+_COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$")
+
+
+@dataclass(frozen=True, slots=True)
+class DatasetLock:
+ """One immutable Hub dataset selection."""
+
+ repo_id: str
+ revision: str
+ supervision_type: str
+
+
+def load_dataset_lock(
+ algorithm: Algorithm, lock_path: str | Path = DEFAULT_LOCK_PATH
+) -> DatasetLock:
+ """Load and verify one independent repository lock.
+
+ Args:
+ algorithm: Offline supervision family to resolve.
+ lock_path: JSON lock file containing immutable Hub revisions.
+
+ Returns:
+ Validated repository selection for the requested algorithm.
+
+ Raises:
+ ValueError: If the algorithm, lock schema, repository, supervision, or revision is invalid.
+ """
+ if algorithm not in DATASET_REPO_IDS:
+ raise ValueError(f"unsupported offline smoke algorithm: {algorithm!r}")
+ path = Path(lock_path).expanduser()
+ with path.open(encoding="utf-8") as handle:
+ payload = json.load(handle)
+ if payload.get("schema_version") != 1 or not isinstance(payload.get("datasets"), dict):
+ raise ValueError(f"invalid offline smoke lock schema: {path}")
+ entry = payload["datasets"].get(algorithm)
+ if not isinstance(entry, dict):
+ raise ValueError(f"offline smoke lock has no {algorithm!r} entry: {path}")
+ lock = DatasetLock(
+ repo_id=entry.get("repo_id"),
+ revision=entry.get("revision"),
+ supervision_type=entry.get("supervision_type"),
+ )
+ expected_supervision = "demonstration" if algorithm == "sft" else "preference"
+ if lock.repo_id != DATASET_REPO_IDS[algorithm]:
+ raise ValueError(
+ f"locked repo_id {lock.repo_id!r} disagrees with profiles.py "
+ f"{DATASET_REPO_IDS[algorithm]!r}"
+ )
+ if lock.supervision_type != expected_supervision:
+ raise ValueError(
+ f"locked supervision {lock.supervision_type!r} must be {expected_supervision!r}"
+ )
+ if not isinstance(lock.revision, str) or not _COMMIT_SHA.fullmatch(lock.revision):
+ detail = (
+ "initial publication is still pending"
+ if lock.revision == PENDING_REVISION
+ else "not a commit SHA"
+ )
+ raise ValueError(
+ f"offline smoke revision for {algorithm!r} is {detail}: {lock.revision!r}; "
+ "publish the dataset and pin its 40-character commit SHA"
+ )
+ return lock
+
+
+def prepare_dataset(
+ *,
+ algorithm: Algorithm,
+ profile_name: str,
+ world_size: int,
+ output_root: str | Path = DEFAULT_OUTPUT_ROOT,
+ per_device_batch_size: int = 1,
+ batches_per_rank: int = 2,
+ allow_repeat: bool = False,
+ offline: bool = False,
+ lock_path: str | Path = DEFAULT_LOCK_PATH,
+ download_fn: SnapshotDownload | None = None,
+) -> Path:
+ """Materialize exactly the records required by one distributed smoke run.
+
+ Args:
+ algorithm: ``sft`` or ``offline-dpo`` supervision family.
+ profile_name: Model-specific runtime alias published by the dataset repository.
+ world_size: Number of distributed ranks in the target run.
+ output_root: Root directory for ready-to-use datasets.
+ per_device_batch_size: Number of records consumed by each rank per batch.
+ batches_per_rank: Exact number of rank-local batches to materialize.
+ allow_repeat: Whether to cycle the base fixture when more rows are requested.
+ offline: Whether Hub access must use already downloaded local files only.
+ lock_path: JSON lock file containing immutable Hub revisions.
+ download_fn: Optional snapshot downloader used by tests.
+
+ Returns:
+ Path to the atomically published, self-contained profile directory.
+
+ Raises:
+ FileExistsError: If the requested output profile already exists.
+ ValueError: If sizing, lock data, source records, or media violate their contracts.
+ """
+ for name, value in (
+ ("world_size", world_size),
+ ("per_device_batch_size", per_device_batch_size),
+ ("batches_per_rank", batches_per_rank),
+ ):
+ _require_positive_int(value, name)
+ if profile_name not in GPU_ALIAS_TO_PROFILE:
+ aliases = ", ".join(GPU_ALIAS_TO_PROFILE)
+ raise ValueError(
+ f"unknown published runtime alias {profile_name!r}; choose one of: {aliases}. "
+ "Canonical task profile names describe contracts but are not Hub directories."
+ )
+ source_profile = profile_name
+ requested_rows = world_size * per_device_batch_size * batches_per_rank
+ lock = load_dataset_lock(algorithm, lock_path)
+ root = Path(output_root).expanduser().resolve()
+ target = root / algorithm / source_profile
+ if target.exists():
+ raise FileExistsError(f"offline smoke output already exists: {target}")
+ target.parent.mkdir(parents=True, exist_ok=True)
+
+ download = snapshot_download if download_fn is None else download_fn
+ with tempfile.TemporaryDirectory(prefix=".offline-smoke-download-", dir=root) as download_dir:
+ snapshot = Path(
+ download(
+ repo_id=lock.repo_id,
+ repo_type="dataset",
+ revision=lock.revision,
+ local_dir=download_dir,
+ local_files_only=offline,
+ )
+ ).resolve()
+ source_manifest = snapshot / "profiles" / source_profile / "train.jsonl"
+ rows = _load_rows(source_manifest, supervision_type=lock.supervision_type)
+ if requested_rows > len(rows) and not allow_repeat:
+ raise ValueError(
+ f"profile {source_profile!r} has {len(rows)} base rows but {requested_rows} "
+ "are required; pass --allow-repeat to cycle smoke-only records explicitly"
+ )
+ selected = [rows[index % len(rows)] for index in range(requested_rows)]
+ staging = Path(tempfile.mkdtemp(prefix=f".{source_profile}-", dir=target.parent))
+ try:
+ with (staging / "train.jsonl").open("w", encoding="utf-8") as handle:
+ for row in selected:
+ rewritten = _materialize_row(row, source_manifest.parent, snapshot, staging)
+ handle.write(
+ json.dumps(
+ rewritten, ensure_ascii=False, separators=(",", ":"), sort_keys=True
+ )
+ + "\n"
+ )
+ summary = validate_dataset(
+ staging,
+ algorithm=algorithm,
+ profile_name=profile_name,
+ expected_rows=requested_rows,
+ )
+ materialization = {
+ "algorithm_profile": profile_name,
+ "allow_repeat": allow_repeat,
+ "repo_id": lock.repo_id,
+ "revision": lock.revision,
+ "row_count": requested_rows,
+ "source_profile": source_profile,
+ "validation": summary,
+ }
+ (staging / "materialization.json").write_text(
+ json.dumps(materialization, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ os.replace(staging, target)
+ finally:
+ if staging.exists():
+ shutil.rmtree(staging)
+ return target
+
+
+def _load_rows(path: Path, *, supervision_type: str) -> list[DatasetRecordV2]:
+ if not path.is_file():
+ raise FileNotFoundError(f"pinned snapshot has no ready-to-use profile manifest: {path}")
+ rows = []
+ with path.open(encoding="utf-8") as handle:
+ for line_number, line in enumerate(handle, start=1):
+ if not line.strip():
+ raise ValueError(f"blank JSONL row at {path}:{line_number}")
+ parsed = DatasetRecordV2.model_validate_json(line)
+ if parsed.supervision.type != supervision_type:
+ raise ValueError(
+ f"{path}:{line_number} has {parsed.supervision.type!r} supervision, "
+ f"expected {supervision_type!r}"
+ )
+ rows.append(parsed)
+ if not rows:
+ raise ValueError(f"offline smoke profile manifest is empty: {path}")
+ return rows
+
+
+def _materialize_row(
+ record: DatasetRecordV2,
+ manifest_dir: Path,
+ snapshot_root: Path,
+ staging: Path,
+) -> Dict[str, Any]:
+ payload = record.model_dump(mode="json", exclude_none=True)
+ for media in _media_dicts(payload):
+ raw_path = Path(media["path"])
+ if raw_path.is_absolute():
+ raise ValueError(f"Hub smoke manifests cannot use absolute media paths: {raw_path}")
+ source = (manifest_dir / raw_path).resolve(strict=True)
+ if not source.is_relative_to(snapshot_root) or not source.is_file():
+ raise ValueError(f"Hub smoke media path escapes its pinned snapshot: {raw_path}")
+ relative = source.relative_to(snapshot_root)
+ if not relative.parts or relative.parts[0] != "media":
+ raise ValueError(f"Hub smoke media must live under repo-root media/: {raw_path}")
+ destination = staging / relative
+ if not destination.exists():
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ os.link(source, destination)
+ except OSError:
+ shutil.copy2(source, destination)
+ media["path"] = relative.as_posix()
+ return payload
+
+
+def _media_dicts(payload: Mapping[str, Any]):
+ yield from payload["input"].get("media", ())
+ supervision = payload["supervision"]
+ for candidate_name in ("target", "chosen", "rejected"):
+ candidate = supervision.get(candidate_name)
+ if candidate is not None:
+ yield from candidate["media"]
+
+
+def _require_positive_int(value: object, name: str) -> None:
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+ raise ValueError(f"{name} must be an integer >= 1, got {value!r}")
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--algorithm", choices=tuple(DATASET_REPO_IDS), required=True)
+ parser.add_argument("--profile", choices=tuple(GPU_ALIAS_TO_PROFILE), required=True)
+ parser.add_argument("--world-size", type=int, required=True)
+ parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT)
+ parser.add_argument("--per-device-batch-size", type=int, default=1)
+ parser.add_argument("--batches-per-rank", type=int, default=2)
+ parser.add_argument("--allow-repeat", action="store_true")
+ parser.add_argument("--offline", action="store_true")
+ parser.add_argument("--lock-path", type=Path, default=DEFAULT_LOCK_PATH)
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Prepare one pinned dataset profile from command-line arguments.
+
+ Args:
+ argv: Optional argument sequence. Uses ``sys.argv`` when omitted.
+
+ Returns:
+ Process exit code zero after successful materialization.
+ """
+ args = _build_parser().parse_args(argv)
+ path = prepare_dataset(
+ algorithm=args.algorithm,
+ profile_name=args.profile,
+ world_size=args.world_size,
+ output_root=args.output_root,
+ per_device_batch_size=args.per_device_batch_size,
+ batches_per_rank=args.batches_per_rank,
+ allow_repeat=args.allow_repeat,
+ offline=args.offline,
+ lock_path=args.lock_path,
+ )
+ print(path)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dataset/offline_smoke/profiles.py b/dataset/offline_smoke/profiles.py
new file mode 100644
index 000000000..d273719ee
--- /dev/null
+++ b/dataset/offline_smoke/profiles.py
@@ -0,0 +1,317 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Canonical task catalog for the public SFT and offline-DPO smoke datasets.
+
+Profiles reuse Flow-Factory's official pipeline contract rather than defining a
+second dataset-only type system. Model identifiers remain strings, so this
+module never imports model adapters such as Bagel or their optional runtimes.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Mapping
+
+from flow_factory.contracts import (
+ InputMediaBinding,
+ InputMediaOrder,
+ InputMediaRule,
+ MediaFormat,
+ MediaType,
+ NegativePromptPolicy,
+ PipelineIOContract,
+ RateRequirement,
+)
+from flow_factory.models.pipeline_contracts import (
+ IMAGE_FORMAT,
+ VIDEO_FORMAT_OPTIONAL_FPS,
+ audio_video_output_contract,
+ image_output_contract,
+ video_output_contract,
+)
+
+SFT_REPO_ID = "Jayce-Ping/Flow-Factory-SFT-Smoke"
+OFFLINE_DPO_REPO_ID = "Jayce-Ping/Flow-Factory-Offline-DPO-Smoke"
+DATASET_REPO_IDS: Mapping[str, str] = MappingProxyType(
+ {"sft": SFT_REPO_ID, "offline-dpo": OFFLINE_DPO_REPO_ID}
+)
+
+
+@dataclass(frozen=True, slots=True)
+class SmokeGeometry:
+ """Small real-weight geometry used by a dataset or GPU variant."""
+
+ height: int
+ width: int
+ num_frames: int | None = None
+ frame_rate: float | None = None
+ sample_rate: int | None = None
+ num_inference_steps: int = 2
+
+ @property
+ def duration_seconds(self) -> float | None:
+ """Return the declared video duration when a frame clock is available.
+
+ Returns:
+ Duration in seconds, or ``None`` for image-only geometry.
+ """
+ if self.num_frames is None or self.frame_rate is None:
+ return None
+ return self.num_frames / self.frame_rate
+
+
+@dataclass(frozen=True, slots=True)
+class GPUSmokeCase:
+ """One model-specific alias and geometry in the GPU handoff catalog."""
+
+ alias: str
+ model_type: str
+ checkpoint: str
+ geometry: SmokeGeometry
+ main_matrix: bool = True
+
+
+@dataclass(frozen=True, slots=True)
+class OfflineSmokeProfile:
+ """One canonical dataset profile shared by SFT and offline DPO."""
+
+ name: str
+ compatible_model_types: tuple[str, ...]
+ contract: PipelineIOContract
+ default_geometry: SmokeGeometry
+ gpu_cases: tuple[GPUSmokeCase, ...] = ()
+
+ @property
+ def profile_id(self) -> str:
+ """Return the stable canonical profile identifier.
+
+ Returns:
+ Canonical profile name.
+ """
+ return self.name
+
+ @property
+ def gpu_aliases(self) -> tuple[str, ...]:
+ """Return model-specific aliases that use this task profile.
+
+ Returns:
+ Ordered runtime alias tuple.
+ """
+ return tuple(case.alias for case in self.gpu_cases)
+
+
+def output_media_types(contract: PipelineIOContract) -> tuple[str, ...]:
+ """Return the exact output sequence without assuming a modality family.
+
+ Args:
+ contract: Pipeline contract whose output sequence is projected.
+
+ Returns:
+ Ordered public media type names.
+ """
+ return tuple(item.type.value for item in contract.output_media.items)
+
+
+_NO_NEGATIVE = NegativePromptPolicy.UNSUPPORTED
+_IMAGE = SmokeGeometry(256, 256)
+_WAN = SmokeGeometry(240, 240, 5, 24.0)
+_LTX = SmokeGeometry(128, 192, 9, 24.0, 16000)
+_H3 = SmokeGeometry(64, 96, 124, 24.0, 32000)
+_AUDIO_REFERENCE_FORMAT = MediaFormat(
+ type=MediaType.AUDIO,
+ fps=RateRequirement.NOT_APPLICABLE,
+ sample_rate=RateRequirement.OPTIONAL,
+)
+
+
+def _image_rule(
+ min_count: int,
+ max_count: int,
+ slots: tuple[str, ...] = (),
+ required_slots: tuple[str, ...] = (),
+) -> InputMediaRule:
+ return InputMediaRule(IMAGE_FORMAT, min_count, max_count, slots, required_slots)
+
+
+_T2I = image_output_contract(negative_prompt=_NO_NEGATIVE)
+_I2I = image_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ input_image_min_count=1,
+ input_image_max_count=1,
+)
+_MRI2I = image_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ input_image_min_count=2,
+ input_image_max_count=2,
+ input_order=InputMediaOrder.WITHIN_TYPE,
+)
+_T2V = video_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ output_fps=RateRequirement.REQUIRED,
+)
+_I2V = video_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ input_image_min_count=1,
+ input_image_max_count=1,
+ input_image_slots=("first_frame",),
+ required_input_image_slots=("first_frame",),
+ output_fps=RateRequirement.REQUIRED,
+)
+_FL2V = video_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ input_image_min_count=1,
+ input_image_max_count=2,
+ input_image_slots=("first_frame", "last_frame"),
+ required_input_image_slots=("first_frame",),
+ output_fps=RateRequirement.REQUIRED,
+)
+_T2AV = audio_video_output_contract(negative_prompt=_NO_NEGATIVE)
+_I2AV = audio_video_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ input_rules=(_image_rule(1, 1, ("first_frame",), ("first_frame",)),),
+)
+_FL2AV = audio_video_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ input_rules=(_image_rule(1, 2, ("first_frame", "last_frame")),),
+ input_order=InputMediaOrder.WITHIN_TYPE,
+)
+_REF2AV = audio_video_output_contract(
+ negative_prompt=_NO_NEGATIVE,
+ input_rules=(
+ InputMediaRule(format=IMAGE_FORMAT, min_count=0, max_count=9),
+ InputMediaRule(format=VIDEO_FORMAT_OPTIONAL_FPS, min_count=0, max_count=3),
+ InputMediaRule(format=_AUDIO_REFERENCE_FORMAT, min_count=0, max_count=3),
+ ),
+ input_binding=InputMediaBinding.ORDERED_REFERENCES,
+ input_order=InputMediaOrder.GLOBAL,
+ min_input_media_count=1,
+ max_input_media_count=12,
+ required_any_input_types=(MediaType.IMAGE, MediaType.VIDEO),
+)
+
+
+_CHECKPOINTS = {
+ "sd35-t2i": "stabilityai/stable-diffusion-3.5-medium",
+ "image-i2i": "black-forest-labs/FLUX.1-Kontext-dev",
+ "bagel-mri2i": "ByteDance-Seed/BAGEL-7B-MoT",
+ "wan-t2v": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
+ "wan-i2v-first": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
+ "wan-flf2v": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
+ "ltx2-t2av": "Lightricks/LTX-2",
+ "ltx2-i2av": "Lightricks/LTX-2",
+ "h3-t2va": "MiniMaxAI/MiniMax-H3",
+ "h3-fl2va": "MiniMaxAI/MiniMax-H3",
+ "h3-ref2va": "MiniMaxAI/MiniMax-H3",
+}
+
+
+def _case(
+ alias: str,
+ model: str,
+ geometry: SmokeGeometry,
+ main: bool = True,
+) -> GPUSmokeCase:
+ return GPUSmokeCase(alias, model, _CHECKPOINTS[alias], geometry, main)
+
+
+_MODEL_TYPES = {
+ "text_to_image": "sd3-5 flux1 flux2 flux2-klein qwen-image z-image bagel sensenova",
+ "image_to_image": "flux1-kontext flux2 flux2-klein qwen-image-edit-plus bagel sensenova",
+ "multi_image_to_image": "flux2 flux2-klein qwen-image-edit-plus bagel sensenova",
+ "text_to_video": "wan2_t2v",
+ "first_frame_to_video": "wan2_i2v",
+ "first_last_frame_to_video": "wan2_i2v",
+ "text_to_audio_video": "ltx2_t2av minimax-h3-t2va",
+ "first_frame_to_audio_video": "ltx2_i2av minimax-h3-fl2va",
+ "first_last_frame_to_audio_video": "minimax-h3-fl2va",
+ "ordered_references_to_audio_video": "minimax-h3-ref2va",
+}
+_PROFILE_SPECS = (
+ ("text_to_image", _T2I, _IMAGE, (_case("sd35-t2i", "sd3-5", _IMAGE),)),
+ ("image_to_image", _I2I, _IMAGE, (_case("image-i2i", "flux1-kontext", _IMAGE, False),)),
+ ("multi_image_to_image", _MRI2I, _IMAGE, (_case("bagel-mri2i", "bagel", _IMAGE),)),
+ ("text_to_video", _T2V, _WAN, (_case("wan-t2v", "wan2_t2v", _WAN),)),
+ ("first_frame_to_video", _I2V, _WAN, (_case("wan-i2v-first", "wan2_i2v", _WAN),)),
+ ("first_last_frame_to_video", _FL2V, _WAN, (_case("wan-flf2v", "wan2_i2v", _WAN),)),
+ (
+ "text_to_audio_video",
+ _T2AV,
+ _LTX,
+ (_case("ltx2-t2av", "ltx2_t2av", _LTX), _case("h3-t2va", "minimax-h3-t2va", _H3)),
+ ),
+ (
+ "first_frame_to_audio_video",
+ _I2AV,
+ _LTX,
+ (_case("ltx2-i2av", "ltx2_i2av", _LTX),),
+ ),
+ (
+ "first_last_frame_to_audio_video",
+ _FL2AV,
+ _H3,
+ (_case("h3-fl2va", "minimax-h3-fl2va", _H3),),
+ ),
+ (
+ "ordered_references_to_audio_video",
+ _REF2AV,
+ _H3,
+ (_case("h3-ref2va", "minimax-h3-ref2va", _H3),),
+ ),
+)
+_PROFILES = tuple(
+ OfflineSmokeProfile(name, tuple(_MODEL_TYPES[name].split()), contract, geometry, cases)
+ for name, contract, geometry, cases in _PROFILE_SPECS
+)
+
+CANONICAL_PROFILES: Mapping[str, OfflineSmokeProfile] = MappingProxyType(
+ {profile.name: profile for profile in _PROFILES}
+)
+GPU_ALIAS_TO_PROFILE: Mapping[str, OfflineSmokeProfile] = MappingProxyType(
+ {case.alias: profile for profile in _PROFILES for case in profile.gpu_cases}
+)
+MAIN_GPU_ALIASES = tuple(
+ "sd35-t2i bagel-mri2i wan-t2v wan-i2v-first wan-flf2v "
+ "ltx2-t2av ltx2-i2av h3-t2va h3-fl2va h3-ref2va".split()
+)
+SUPPLEMENTAL_GPU_ALIASES = ("image-i2i",)
+
+
+def get_profile(name_or_gpu_alias: str) -> OfflineSmokeProfile:
+ """Resolve a canonical profile name or a model-specific GPU alias.
+
+ Args:
+ name_or_gpu_alias: Canonical profile name or runtime alias.
+
+ Returns:
+ Matching immutable smoke profile.
+
+ Raises:
+ KeyError: If no canonical profile or runtime alias matches the value.
+ """
+ try:
+ return CANONICAL_PROFILES[name_or_gpu_alias]
+ except KeyError:
+ try:
+ return GPU_ALIAS_TO_PROFILE[name_or_gpu_alias]
+ except KeyError as error:
+ raise KeyError(f"unknown offline smoke profile {name_or_gpu_alias!r}") from error
+
+
+__all__ = tuple(
+ "CANONICAL_PROFILES DATASET_REPO_IDS GPUSmokeCase GPU_ALIAS_TO_PROFILE MAIN_GPU_ALIASES "
+ "OFFLINE_DPO_REPO_ID OfflineSmokeProfile SFT_REPO_ID SUPPLEMENTAL_GPU_ALIASES "
+ "SmokeGeometry get_profile output_media_types".split()
+)
diff --git a/dataset/offline_smoke/publish.py b/dataset/offline_smoke/publish.py
new file mode 100644
index 000000000..f8304b360
--- /dev/null
+++ b/dataset/offline_smoke/publish.py
@@ -0,0 +1,225 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Publish one validated offline-smoke staging tree to the Hugging Face Hub.
+
+This command is intentionally separate from dataset construction. Building and
+validating fixtures are local, reversible operations; publication creates public
+external state and therefore requires the explicit ``--confirm-public`` flag.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Literal, Sequence
+
+from huggingface_hub import HfApi
+
+from .profiles import OFFLINE_DPO_REPO_ID, SFT_REPO_ID
+
+Algorithm = Literal["sft", "offline-dpo"]
+_SUPERVISION_BY_ALGORITHM = {"sft": "demonstration", "offline-dpo": "preference"}
+
+
+def default_repo_id(algorithm: Algorithm) -> str:
+ """Return the public repository owned by one supervision family.
+
+ Args:
+ algorithm: Offline supervision family.
+
+ Returns:
+ Canonical public Hugging Face dataset repository ID.
+
+ Raises:
+ ValueError: If the algorithm is unsupported.
+ """
+ if algorithm == "sft":
+ return SFT_REPO_ID
+ if algorithm == "offline-dpo":
+ return OFFLINE_DPO_REPO_ID
+ raise ValueError(f"unsupported offline smoke algorithm: {algorithm!r}")
+
+
+def validate_staging_tree(
+ staging_dir: Path,
+ *,
+ algorithm: Algorithm,
+ destination_repo_id: str,
+) -> None:
+ """Reject mismatched or unsafe publication trees before any Hub mutation.
+
+ Args:
+ staging_dir: Local self-contained repository tree to publish.
+ algorithm: Supervision family selected by the publication command.
+ destination_repo_id: Hub dataset repository that would receive the tree.
+
+ Returns:
+ None.
+
+ Raises:
+ ValueError: If identity, supervision, structure, or paths are invalid.
+ """
+ staging_dir = staging_dir.resolve()
+ if not staging_dir.is_dir():
+ raise ValueError(f"staging directory does not exist: {staging_dir}")
+ for relative_path in (
+ "README.md",
+ "LICENSE",
+ "dataset_manifest.json",
+ "media",
+ "profiles",
+ "provenance.jsonl",
+ ):
+ if not (staging_dir / relative_path).exists():
+ raise ValueError(
+ f"staging directory is incomplete: missing {relative_path!r} under {staging_dir}"
+ )
+
+ with (staging_dir / "dataset_manifest.json").open(encoding="utf-8") as handle:
+ manifest = json.load(handle)
+ canonical_repo_id = default_repo_id(algorithm)
+ expected_supervision = _SUPERVISION_BY_ALGORITHM[algorithm]
+ if manifest.get("schema_version") != 1 or manifest.get("flow_factory_schema_version") != 2:
+ raise ValueError("staging dataset manifest has an unsupported schema version")
+ if manifest.get("supervision_type") != expected_supervision:
+ raise ValueError(
+ f"{algorithm!r} publication requires {expected_supervision!r} supervision, "
+ f"found {manifest.get('supervision_type')!r}"
+ )
+ if (
+ destination_repo_id == canonical_repo_id
+ and manifest.get("repository_id") != canonical_repo_id
+ ):
+ raise ValueError(
+ f"canonical destination {canonical_repo_id!r} requires a matching staging repository_id, "
+ f"found {manifest.get('repository_id')!r}"
+ )
+ runtime_aliases = manifest.get("runtime_aliases")
+ if (
+ not isinstance(runtime_aliases, list)
+ or not runtime_aliases
+ or any(not isinstance(alias, str) or not alias for alias in runtime_aliases)
+ or len(runtime_aliases) != len(set(runtime_aliases))
+ ):
+ raise ValueError("staging dataset manifest must declare unique non-empty runtime aliases")
+ profile_aliases = {path.name for path in (staging_dir / "profiles").iterdir() if path.is_dir()}
+ if profile_aliases != set(runtime_aliases):
+ raise ValueError("staging profile directories disagree with manifest runtime_aliases")
+
+ for path in staging_dir.rglob("*"):
+ if path.is_symlink():
+ raise ValueError(f"public staging trees cannot contain symlinks: {path}")
+ if path.is_file() and not path.resolve().is_relative_to(staging_dir):
+ raise ValueError(f"staging file escapes publication root: {path}")
+
+
+def publish_staging_tree(
+ *,
+ algorithm: Algorithm,
+ staging_dir: Path,
+ repo_id: str | None = None,
+ commit_message: str,
+ confirm_public: bool,
+) -> str:
+ """Create or update one public dataset repository.
+
+ Args:
+ algorithm: Offline supervision family being published.
+ staging_dir: Validated local repository tree.
+ repo_id: Optional noncanonical destination repository override.
+ commit_message: Hub commit message.
+ confirm_public: Explicit acknowledgement of public external state.
+
+ Returns:
+ Immutable Hub commit SHA returned by the upload.
+
+ Raises:
+ ValueError: If public confirmation or staging identity is invalid.
+ """
+ if not confirm_public:
+ raise ValueError("public dataset publication requires --confirm-public")
+ resolved_repo_id = default_repo_id(algorithm) if repo_id is None else repo_id
+ validate_staging_tree(
+ staging_dir,
+ algorithm=algorithm,
+ destination_repo_id=resolved_repo_id,
+ )
+ api = HfApi()
+ api.create_repo(
+ repo_id=resolved_repo_id,
+ repo_type="dataset",
+ private=False,
+ exist_ok=True,
+ )
+ commit = api.upload_folder(
+ repo_id=resolved_repo_id,
+ repo_type="dataset",
+ folder_path=str(staging_dir.resolve()),
+ commit_message=commit_message,
+ )
+ return commit.oid
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ """Build the publication-only command-line interface."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--algorithm", choices=("sft", "offline-dpo"), required=True)
+ parser.add_argument("--staging-dir", type=Path, required=True)
+ parser.add_argument("--repo-id", default=None)
+ parser.add_argument(
+ "--commit-message",
+ default="Publish deterministic Flow-Factory offline smoke fixtures",
+ )
+ parser.add_argument(
+ "--confirm-public",
+ action="store_true",
+ help="Acknowledge that this command creates or updates a public Hub dataset.",
+ )
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Publish one staging tree and print a machine-readable result.
+
+ Args:
+ argv: Optional argument sequence. Uses ``sys.argv`` when omitted.
+
+ Returns:
+ Process exit code zero after a successful Hub commit.
+ """
+ args = _build_parser().parse_args(argv)
+ commit_sha = publish_staging_tree(
+ algorithm=args.algorithm,
+ staging_dir=args.staging_dir,
+ repo_id=args.repo_id,
+ commit_message=args.commit_message,
+ confirm_public=args.confirm_public,
+ )
+ print(
+ json.dumps(
+ {
+ "algorithm": args.algorithm,
+ "repo_id": args.repo_id or default_repo_id(args.algorithm),
+ "revision": commit_sha,
+ },
+ sort_keys=True,
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dataset/offline_smoke/validate.py b/dataset/offline_smoke/validate.py
new file mode 100644
index 000000000..1842c8e63
--- /dev/null
+++ b/dataset/offline_smoke/validate.py
@@ -0,0 +1,233 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Thin validation boundary for materialized offline-smoke datasets.
+
+The model adapter remains authoritative for encoded geometry and condition/output
+semantics. This module only applies the existing public V2 and pipeline contracts,
+checks that media stay inside the dataset root, uniquely decodes referenced files,
+and rejects byte-identical offline-DPO candidates.
+"""
+
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+from typing import Any, Dict, Literal, Mapping, Tuple
+
+from flow_factory.contracts import (
+ validate_pipeline_model_input,
+ validate_pipeline_output_candidate,
+)
+from flow_factory.data_utils.offline_dataset import (
+ DEFAULT_MEDIA_DECODERS,
+ load_offline_manifest,
+)
+from flow_factory.data_utils.schema import (
+ DemonstrationSupervision,
+ MediaAsset,
+ NormalizedDatasetRecord,
+ NormalizedOutputCandidate,
+ PreferenceSupervision,
+)
+
+from .profiles import get_profile
+
+Algorithm = Literal["sft", "offline-dpo"]
+_SUPERVISION_BY_ALGORITHM: Mapping[Algorithm, str] = {
+ "sft": "demonstration",
+ "offline-dpo": "preference",
+}
+_HASH_CHUNK_SIZE = 1024 * 1024
+
+
+def validate_dataset(
+ dataset_dir: str | Path,
+ *,
+ algorithm: Algorithm,
+ profile_name: str,
+ expected_rows: int | None = None,
+) -> Dict[str, Any]:
+ """Validate one self-contained materialized dataset without loading a model.
+
+ Args:
+ dataset_dir: Directory containing ``train.jsonl`` and local media.
+ algorithm: Supervision family required by the trainer.
+ profile_name: Canonical task profile or model-specific runtime alias.
+ expected_rows: Optional exact row-count requirement.
+
+ Returns:
+ Validation summary with profile identity and unique media hashes.
+
+ Raises:
+ FileNotFoundError: If the dataset or a referenced media file is missing.
+ TypeError: If a record violates a typed schema or contract field.
+ ValueError: If records, paths, media, or preference arms are invalid.
+ """
+ supervision_type = _require_algorithm(algorithm)
+ if expected_rows is not None:
+ _require_positive_int(expected_rows, "expected_rows")
+ root = Path(dataset_dir).expanduser().resolve()
+ if not root.is_dir():
+ raise FileNotFoundError(f"offline smoke dataset directory does not exist: {root}")
+
+ profile = get_profile(profile_name)
+ records = load_offline_manifest(
+ root / "train.jsonl",
+ supervision_type=supervision_type,
+ dataset_dir=root,
+ )
+ if expected_rows is not None and len(records) != expected_rows:
+ raise ValueError(
+ f"offline smoke dataset must contain exactly {expected_rows} rows, "
+ f"found {len(records)}"
+ )
+
+ digest_cache: Dict[str, Tuple[Path, str]] = {}
+ decode_cache: Dict[Tuple[str, Path], object] = {}
+ for row_index, record in enumerate(records):
+ _validate_contract(record, profile.contract, row_index=row_index)
+ for asset in _record_media(record):
+ cached = digest_cache.get(asset.path)
+ if cached is None:
+ path = _require_contained_file(asset.path, root=root)
+ cached = (path, _sha256_file(path))
+ digest_cache[asset.path] = cached
+ else:
+ path = cached[0]
+ cache_key = (asset.type, path)
+ if cache_key not in decode_cache:
+ decode_cache[cache_key] = _decode_media(asset, context=f"row {row_index}")
+ if isinstance(record.supervision, PreferenceSupervision):
+ _require_distinct_candidates(
+ record.supervision.chosen,
+ record.supervision.rejected,
+ digest_cache=digest_cache,
+ context=f"row {row_index}",
+ )
+
+ return {
+ "algorithm": algorithm,
+ "requested_profile": profile_name,
+ "canonical_profile": profile.profile_id,
+ "row_count": len(records),
+ "media_file_count": len(digest_cache),
+ "media_sha256": {
+ path.relative_to(root).as_posix(): digest for path, digest in digest_cache.values()
+ },
+ }
+
+
+def _require_algorithm(algorithm: str) -> str:
+ if algorithm not in _SUPERVISION_BY_ALGORITHM:
+ raise ValueError(
+ f"unsupported offline smoke algorithm {algorithm!r}; "
+ f"expected one of {tuple(_SUPERVISION_BY_ALGORITHM)!r}"
+ )
+ return _SUPERVISION_BY_ALGORITHM[algorithm]
+
+
+def _require_positive_int(value: object, name: str) -> None:
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise TypeError(f"{name} must be an integer >= 1, got {type(value).__name__}: {value!r}")
+ if value < 1:
+ raise ValueError(f"{name} must be >= 1, got {value}")
+
+
+def _validate_contract(record, contract, *, row_index: int) -> None:
+ try:
+ validate_pipeline_model_input(record.model_input, contract)
+ for _, candidate in _record_candidates(record):
+ validate_pipeline_output_candidate(candidate.media, contract)
+ except (TypeError, ValueError) as exc:
+ raise type(exc)(
+ f"offline smoke row {row_index} violates its profile contract: {exc}"
+ ) from exc
+
+
+def _record_candidates(
+ record: NormalizedDatasetRecord,
+) -> Tuple[Tuple[str, NormalizedOutputCandidate], ...]:
+ supervision = record.supervision
+ if isinstance(supervision, DemonstrationSupervision):
+ return (("target", supervision.target),)
+ if isinstance(supervision, PreferenceSupervision):
+ return (("chosen", supervision.chosen), ("rejected", supervision.rejected))
+ raise TypeError(f"unsupported normalized supervision: {type(supervision).__name__}")
+
+
+def _record_media(record: NormalizedDatasetRecord):
+ yield from record.model_input.media
+ for _, candidate in _record_candidates(record):
+ yield from candidate.media
+
+
+def _require_contained_file(path_value: str, *, root: Path) -> Path:
+ path = Path(path_value)
+ try:
+ lexical_relative = path.relative_to(root)
+ except ValueError as exc:
+ raise ValueError(f"offline smoke media path escapes dataset root: {path}") from exc
+ cursor = root
+ for part in lexical_relative.parts:
+ cursor /= part
+ if cursor.is_symlink():
+ raise ValueError(f"offline smoke media paths cannot traverse symlinks: {cursor}")
+ try:
+ resolved = path.resolve(strict=True)
+ except FileNotFoundError as exc:
+ raise FileNotFoundError(f"offline smoke media file does not exist: {path}") from exc
+ if not resolved.is_relative_to(root):
+ raise ValueError(f"offline smoke media path escapes dataset root: {path}")
+ if not resolved.is_file():
+ raise ValueError(f"offline smoke media path is not a regular file: {path}")
+ return resolved
+
+
+def _decode_media(asset: MediaAsset, *, context: str) -> object:
+ decoder = DEFAULT_MEDIA_DECODERS.get(asset.type)
+ if decoder is None:
+ raise ValueError(f"{context} has no decoder for media type {asset.type!r}")
+ try:
+ return decoder(asset)
+ except (ImportError, OSError, TypeError, ValueError) as exc:
+ raise type(exc)(f"{context} failed to decode {asset.type} {asset.path!r}: {exc}") from exc
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(_HASH_CHUNK_SIZE), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _require_distinct_candidates(
+ chosen: NormalizedOutputCandidate,
+ rejected: NormalizedOutputCandidate,
+ *,
+ digest_cache: Mapping[str, Tuple[Path, str]],
+ context: str,
+) -> None:
+ def signature(candidate: NormalizedOutputCandidate) -> Tuple[Tuple[str, str], ...]:
+ return tuple(
+ (
+ asset.type,
+ digest_cache[asset.path][1],
+ )
+ for asset in candidate.media
+ )
+
+ if signature(chosen) == signature(rejected):
+ raise ValueError(f"{context} has byte-identical chosen and rejected candidates")
diff --git a/guidance/datasets.md b/guidance/datasets.md
index 7a87075e0..81da9aaac 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -125,6 +125,41 @@ Tiny schema-complete fixtures and configs are available for
[SFT](../examples/sft/lora/sd3_5/default.yaml) and
[offline DPO](../examples/offline_dpo/lora/sd3_5/default.yaml).
+### Public offline smoke datasets
+
+The repository includes one builder for two independent, self-contained public mini datasets:
+
+- [Jayce-Ping/Flow-Factory-SFT-Smoke](https://huggingface.co/datasets/Jayce-Ping/Flow-Factory-SFT-Smoke)
+- [Jayce-Ping/Flow-Factory-Offline-DPO-Smoke](https://huggingface.co/datasets/Jayce-Ping/Flow-Factory-Offline-DPO-Smoke)
+
+Their runtime aliases cover the currently implemented image, video, and ordered `(video, audio)`
+output adapters. The catalog is expressed with the general `PipelineIOContract` output sequence;
+it does not encode audio-video as a special media type. A future audio-only or other ordered output
+can therefore use the same V2 schema and preparation path, although no fixture is published for a
+model family that does not exist in the framework today.
+
+Before a GPU smoke run, materialize exactly two rank-local batches from the pinned Hub revision:
+
+```bash
+python -m dataset.offline_smoke.prepare \
+ --algorithm sft \
+ --profile ltx2-t2av \
+ --world-size 8
+
+python -m dataset.offline_smoke.prepare \
+ --algorithm offline-dpo \
+ --profile h3-ref2va \
+ --world-size 8
+```
+
+The default output is `dataset/_prepared_offline_smoke///train.jsonl` with
+profile-local media. The preparation tool uses the locked immutable dataset revision, selects
+`world_size * per_device_batch_size * batches_per_rank` rows, and validates through the existing
+V2 and canonical task-profile contract boundaries. It does not preprocess target media or create
+latent caches.
+See [`dataset/offline_smoke`](../dataset/offline_smoke/README.md) for construction, provenance, and
+publication details.
+
Evaluation still uses generation acquisition, including when the trainer is SFT or offline DPO.
Consequently, a split enabled through `data.datasets[*].eval` must use one of the legacy
prompt/condition formats documented under [Common task formats](#common-task-formats), not a
diff --git a/guidance/gpu_validation.md b/guidance/gpu_validation.md
index ede5e295f..92935b7a3 100644
--- a/guidance/gpu_validation.md
+++ b/guidance/gpu_validation.md
@@ -101,6 +101,31 @@ recorded in the resolved YAML; do not silently return to a large quality recipe.
| `h3-fl2va` | `examples/grpo/lora/minimax_h3_fl2va/default.yaml` plus H3 debug geometry | `resolution: [64, 96]`, `num_frames: 124`, `frame_rate: 24.0` | `num_inference_steps: 2` | The two offline records are explicit `first_frame`-only and `last_frame`-only cases. Cover both slots together in the additional variant gate. |
| `h3-ref2va` | `examples/grpo/lora/minimax_h3_ref2va/default.yaml` plus H3 debug geometry | `resolution: [64, 96]`, `num_frames: 124`, `frame_rate: 24.0` | `num_inference_steps: 2` | Preserve heterogeneous global reference order; include image, video, and audio references. |
+For the two offline algorithms, construct each ready-to-use alias from its independent pinned
+public dataset before launching distributed workers:
+
+```bash
+python -m dataset.offline_smoke.prepare \
+ --algorithm sft \
+ --profile "${MODE}" \
+ --world-size "${WORLD_SIZE}" \
+ --per-device-batch-size 1 \
+ --batches-per-rank 2
+
+python -m dataset.offline_smoke.prepare \
+ --algorithm offline-dpo \
+ --profile "${MODE}" \
+ --world-size "${WORLD_SIZE}" \
+ --per-device-batch-size 1 \
+ --batches-per-rank 2
+```
+
+Run the command once per shared filesystem rather than once per distributed rank. The SFT and DPO
+repositories have the same alias/input distribution but different supervision and self-contained
+media. `image-i2i` is an additional contract gate outside the 120-job main matrix. The catalog and
+materializer operate on arbitrary ordered output media sequences; currently published fixtures are
+limited to the image, video, and `(video, audio)` outputs implemented by registered adapters.
+
For GRPO set `group_size: 2`, `unique_sample_num_per_epoch: 1`, and
`gradient_accumulation_steps: auto`; this avoids a degenerate one-candidate
advantage while retaining one optimizer step per epoch. For TDM set
@@ -139,7 +164,7 @@ backends and all four algorithms.
| Wan T2V | `Wan2.1-T2V-14B-Diffusers`, `Wan2.2-TI2V-5B-Diffusers`, `Wan2.2-T2V-A14B-Diffusers` |
| Wan I2V first-only | `Wan2.1-I2V-14B-480P-Diffusers`, `Wan2.1-I2V-14B-720P-Diffusers`, `Wan2.2-I2V-A14B-Diffusers` |
| Wan first/last | `Wan2.1-I2V-14B-720P-Diffusers`, `Wan2.2-I2V-A14B-Diffusers` |
-| LTX2 T2AV and I2AV | `Lightricks/LTX-2.3` or its canonical official-Diffusers repository revision |
+| LTX2 T2AV and I2AV | `dg845/LTX-2.3-Diffusers` |
| MiniMax H3 | The same checkpoint is covered separately by T2VA, FL2VA, and Ref2VA inputs. Add an FL2VA first-plus-last fixture to complement the first-only/last-only main jobs. |
Wan2.2 TI2V-5B uses expanded timesteps. Official Diffusers ignores a supplied
diff --git a/tests/dataset/test_offline_smoke_build.py b/tests/dataset/test_offline_smoke_build.py
new file mode 100644
index 000000000..fe01f2263
--- /dev/null
+++ b/tests/dataset/test_offline_smoke_build.py
@@ -0,0 +1,243 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import hashlib
+import json
+import os
+from pathlib import Path
+from typing import Any, Iterator, Mapping
+
+import av
+import numpy as np
+import pytest
+from PIL import Image
+
+from dataset.offline_smoke.build_mini import RUNTIME_ALIASES, build
+from dataset.offline_smoke.profiles import GPU_ALIAS_TO_PROFILE
+from dataset.offline_smoke.validate import validate_dataset
+from flow_factory.contracts import (
+ validate_pipeline_model_input,
+ validate_pipeline_output_candidate,
+)
+from flow_factory.data_utils.offline_dataset import load_offline_manifest
+from flow_factory.models.registry import get_model_adapter_class
+
+ROWS = 3
+AV_ALIASES = (
+ "ltx2-t2av",
+ "ltx2-i2av",
+ "h3-t2va",
+ "h3-fl2va",
+ "h3-ref2va",
+)
+
+
+@pytest.fixture(scope="module")
+def built_repos(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> tuple[Path, Path, Path]:
+ root = tmp_path_factory.mktemp("offline-smoke-build")
+ sft_root, dpo_root = build(
+ root / "staging",
+ seed=20260830,
+ replace=False,
+ records_per_alias=ROWS,
+ )
+ return root, sft_root, dpo_root
+
+
+def test_all_runtime_aliases_pass_public_validation(
+ built_repos: tuple[Path, Path, Path],
+) -> None:
+ root, sft_root, dpo_root = built_repos
+ for algorithm, repo, supervision_type in (
+ ("sft", sft_root, "demonstration"),
+ ("offline-dpo", dpo_root, "preference"),
+ ):
+ manifest = json.loads((repo / "dataset_manifest.json").read_text(encoding="utf-8"))
+ assert manifest["condition_endpoint_check"] == {
+ "metric": "decoded_rgb_max_absolute_difference",
+ "tolerance": 0,
+ }
+ profile_names = {path.name for path in (repo / "profiles").iterdir()}
+ assert profile_names == set(RUNTIME_ALIASES)
+ for alias in RUNTIME_ALIASES:
+ rows = _rows(repo, alias)
+ assert len(rows) == ROWS
+ assert {row["supervision"]["type"] for row in rows} == {supervision_type}
+ materialized = _materialize(repo, alias, root / "materialized" / algorithm / alias)
+ summary = validate_dataset(
+ materialized,
+ algorithm=algorithm,
+ profile_name=alias,
+ expected_rows=ROWS,
+ )
+ assert summary["row_count"] == ROWS
+
+
+def test_runtime_geometries_and_av_order(
+ built_repos: tuple[Path, Path, Path],
+) -> None:
+ _, sft_root, dpo_root = built_repos
+ expected_frames = {"wan-t2v": 5, "ltx2-t2av": 9, "h3-t2va": 124}
+ for alias, count in expected_frames.items():
+ row = _rows(sft_root, alias)[0]
+ video = _resolve(sft_root, alias, row["supervision"]["target"]["media"][0])
+ assert len(_decode_video(video)) == count
+
+ for repo, candidate_names in ((sft_root, ("target",)), (dpo_root, ("chosen", "rejected"))):
+ for alias in AV_ALIASES:
+ for row in _rows(repo, alias):
+ for name in candidate_names:
+ media = row["supervision"][name]["media"]
+ assert [item["type"] for item in media] == ["video", "audio"]
+
+
+def test_generated_rows_remain_legal_subsets_of_real_adapter_contracts(
+ built_repos: tuple[Path, Path, Path],
+) -> None:
+ _, sft_root, _ = built_repos
+ for alias in RUNTIME_ALIASES:
+ if alias == "bagel-mri2i":
+ # The Bagel contract is covered behind its optional-kernel seam in
+ # tests/models/test_bagel_output_codec.py.
+ continue
+ profile = GPU_ALIAS_TO_PROFILE[alias]
+ case = next(case for case in profile.gpu_cases if case.alias == alias)
+ adapter_type = get_model_adapter_class(case.model_type)
+ contract = adapter_type.pipeline_io_contract
+ manifest = sft_root / "profiles" / alias / "train.jsonl"
+ records = load_offline_manifest(
+ manifest,
+ supervision_type="demonstration",
+ dataset_dir=manifest.parent,
+ )
+ for record in records:
+ validate_pipeline_model_input(record.model_input, contract)
+ validate_pipeline_output_candidate(record.supervision.target.media, contract)
+
+
+def test_h3_sparse_frame_slots_and_dpo_endpoints(
+ built_repos: tuple[Path, Path, Path],
+) -> None:
+ _, _, dpo_root = built_repos
+ rows = _rows(dpo_root, "h3-fl2va")
+ assert [[item["slot"] for item in row["input"]["media"]] for row in rows] == [
+ ["first_frame"],
+ ["last_frame"],
+ ["first_frame", "last_frame"],
+ ]
+
+ for alias in ("wan-i2v-first", "wan-flf2v", "ltx2-i2av", "h3-fl2va"):
+ for row in _rows(dpo_root, alias):
+ chosen = _candidate_video(dpo_root, alias, row, "chosen")
+ rejected = _candidate_video(dpo_root, alias, row, "rejected")
+ for condition in row["input"]["media"]:
+ slot = condition.get("slot")
+ if slot not in {"first_frame", "last_frame"}:
+ continue
+ endpoint = 0 if slot == "first_frame" else -1
+ expected = np.asarray(
+ Image.open(_resolve(dpo_root, alias, condition)).convert("RGB")
+ )
+ assert np.array_equal(chosen[endpoint], expected)
+ assert np.array_equal(rejected[endpoint], expected)
+
+
+def test_dpo_arms_are_content_distinct(
+ built_repos: tuple[Path, Path, Path],
+) -> None:
+ _, _, dpo_root = built_repos
+ digest_cache: dict[Path, str] = {}
+ for alias in RUNTIME_ALIASES:
+ for row in _rows(dpo_root, alias):
+ chosen = _signature(dpo_root, alias, row["supervision"]["chosen"], digest_cache)
+ rejected = _signature(
+ dpo_root,
+ alias,
+ row["supervision"]["rejected"],
+ digest_cache,
+ )
+ assert chosen != rejected
+
+
+def _rows(repo: Path, alias: str) -> list[dict[str, Any]]:
+ path = repo / "profiles" / alias / "train.jsonl"
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
+
+
+def _all_media(row: Mapping[str, Any]) -> Iterator[dict[str, Any]]:
+ yield from row["input"].get("media", ())
+ for name in ("target", "chosen", "rejected"):
+ candidate = row["supervision"].get(name)
+ if candidate is not None:
+ yield from candidate["media"]
+
+
+def _resolve(repo: Path, alias: str, media: Mapping[str, Any]) -> Path:
+ return (repo / "profiles" / alias / media["path"]).resolve()
+
+
+def _materialize(repo: Path, alias: str, destination: Path) -> Path:
+ rows = _rows(repo, alias)
+ destination.mkdir(parents=True)
+ repo = repo.resolve()
+ for row in rows:
+ for media in _all_media(row):
+ source = _resolve(repo, alias, media)
+ relative = source.relative_to(repo)
+ target = destination / relative
+ target.parent.mkdir(parents=True, exist_ok=True)
+ if not target.exists():
+ os.link(source, target)
+ media["path"] = relative.as_posix()
+ (destination / "train.jsonl").write_text(
+ "".join(json.dumps(row, sort_keys=True) + "\n" for row in rows),
+ encoding="utf-8",
+ )
+ return destination
+
+
+def _decode_video(path: Path) -> list[np.ndarray]:
+ with av.open(str(path)) as container:
+ return [frame.to_ndarray(format="rgb24") for frame in container.decode(video=0)]
+
+
+def _candidate_video(
+ repo: Path,
+ alias: str,
+ row: Mapping[str, Any],
+ name: str,
+) -> list[np.ndarray]:
+ media = next(item for item in row["supervision"][name]["media"] if item["type"] == "video")
+ return _decode_video(_resolve(repo, alias, media))
+
+
+def _signature(
+ repo: Path,
+ alias: str,
+ candidate: Mapping[str, Any],
+ cache: dict[Path, str],
+) -> tuple[tuple[str, str], ...]:
+ values = []
+ for media in candidate["media"]:
+ path = _resolve(repo, alias, media)
+ if path not in cache:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ cache[path] = digest.hexdigest()
+ values.append((media["type"], cache[path]))
+ return tuple(values)
diff --git a/tests/dataset/test_offline_smoke_prepare.py b/tests/dataset/test_offline_smoke_prepare.py
new file mode 100644
index 000000000..eac5e5011
--- /dev/null
+++ b/tests/dataset/test_offline_smoke_prepare.py
@@ -0,0 +1,260 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any, Dict, Mapping
+
+import pytest
+from PIL import Image
+
+from dataset.offline_smoke.prepare import PENDING_REVISION, prepare_dataset
+from dataset.offline_smoke.profiles import DATASET_REPO_IDS
+from dataset.offline_smoke.validate import validate_dataset
+
+PINNED_SHA = "a" * 40
+
+
+def test_prepare_cli_supports_documented_module_execution() -> None:
+ repo_root = Path(__file__).resolve().parents[2]
+ result = subprocess.run(
+ [sys.executable, "-m", "dataset.offline_smoke.prepare", "--help"],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ assert "--algorithm" in result.stdout
+
+
+def _write_lock(path: Path, *, pending: bool = False) -> None:
+ revision = PENDING_REVISION if pending else PINNED_SHA
+ path.write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "datasets": {
+ "sft": {
+ "repo_id": DATASET_REPO_IDS["sft"],
+ "revision": revision,
+ "supervision_type": "demonstration",
+ },
+ "offline-dpo": {
+ "repo_id": DATASET_REPO_IDS["offline-dpo"],
+ "revision": revision,
+ "supervision_type": "preference",
+ },
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+
+def _record(algorithm: str, *, target: str, rejected: str) -> Dict[str, Any]:
+ supervision: Dict[str, Any]
+ if algorithm == "sft":
+ supervision = {
+ "type": "demonstration",
+ "target": {"media": [{"type": "image", "path": target}]},
+ }
+ else:
+ supervision = {
+ "type": "preference",
+ "chosen": {"media": [{"type": "image", "path": target}]},
+ "rejected": {"media": [{"type": "image", "path": rejected}]},
+ }
+ return {
+ "schema_version": 2,
+ "input": {"prompt": "A deterministic smoke fixture.", "media": []},
+ "supervision": supervision,
+ "metadata": {"usage_tier": "smoke_only"},
+ }
+
+
+def _populate_snapshot(
+ local_dir: Path,
+ *,
+ algorithm: str,
+ profile_dir: str = "sd35-t2i",
+ row_count: int = 4,
+ target_path: str = "../../media/target.png",
+ identical_pair: bool = False,
+) -> None:
+ media_dir = local_dir / "media"
+ manifest_dir = local_dir / "profiles" / profile_dir
+ media_dir.mkdir(parents=True)
+ manifest_dir.mkdir(parents=True)
+ Image.new("RGB", (8, 8), color=(10, 20, 30)).save(media_dir / "target.png")
+ rejected_color = (10, 20, 30) if identical_pair else (30, 20, 10)
+ Image.new("RGB", (8, 8), color=rejected_color).save(media_dir / "rejected.png")
+ rows = [
+ _record(
+ algorithm,
+ target=target_path,
+ rejected="../../media/rejected.png",
+ )
+ for _ in range(row_count)
+ ]
+ (manifest_dir / "train.jsonl").write_text(
+ "".join(json.dumps(row) + "\n" for row in rows),
+ encoding="utf-8",
+ )
+
+
+@pytest.mark.parametrize("algorithm", ["sft", "offline-dpo"])
+def test_prepare_uses_independent_pinned_repo_and_exact_rank_geometry(
+ tmp_path: Path,
+ algorithm: str,
+) -> None:
+ lock_path = tmp_path / "lock.json"
+ _write_lock(lock_path)
+ calls = []
+
+ def download(**kwargs: Any) -> str:
+ calls.append(kwargs)
+ local_dir = Path(kwargs["local_dir"])
+ _populate_snapshot(local_dir, algorithm=algorithm)
+ return str(local_dir)
+
+ prepared = prepare_dataset(
+ algorithm=algorithm,
+ profile_name="sd35-t2i",
+ world_size=2,
+ output_root=tmp_path / "prepared",
+ offline=True,
+ lock_path=lock_path,
+ download_fn=download,
+ )
+
+ assert calls == [
+ {
+ "repo_id": DATASET_REPO_IDS[algorithm],
+ "repo_type": "dataset",
+ "revision": PINNED_SHA,
+ "local_dir": calls[0]["local_dir"],
+ "local_files_only": True,
+ }
+ ]
+ rows = [json.loads(line) for line in (prepared / "train.jsonl").read_text().splitlines()]
+ assert len(rows) == 4
+ assert all(".." not in media["path"] for row in rows for media in _all_media(row))
+ assert (prepared / "media" / "target.png").is_file()
+ assert json.loads((prepared / "materialization.json").read_text())["revision"] == PINNED_SHA
+ summary = validate_dataset(
+ prepared,
+ algorithm=algorithm,
+ profile_name="sd35-t2i",
+ expected_rows=4,
+ )
+ assert summary["row_count"] == 4
+
+
+def test_pending_revision_fails_before_download(tmp_path: Path) -> None:
+ lock_path = tmp_path / "lock.json"
+ _write_lock(lock_path, pending=True)
+
+ def unexpected_download(**kwargs: Any) -> str:
+ raise AssertionError(f"download must not run: {kwargs}")
+
+ with pytest.raises(ValueError, match="publication is still pending"):
+ prepare_dataset(
+ algorithm="sft",
+ profile_name="sd35-t2i",
+ world_size=1,
+ output_root=tmp_path / "prepared",
+ lock_path=lock_path,
+ download_fn=unexpected_download,
+ )
+
+
+def test_canonical_profile_name_fails_before_download(tmp_path: Path) -> None:
+ lock_path = tmp_path / "lock.json"
+ _write_lock(lock_path)
+
+ def unexpected_download(**kwargs: Any) -> str:
+ raise AssertionError(f"download must not run: {kwargs}")
+
+ with pytest.raises(ValueError, match="published runtime alias"):
+ prepare_dataset(
+ algorithm="sft",
+ profile_name="text_to_image",
+ world_size=1,
+ output_root=tmp_path / "prepared",
+ lock_path=lock_path,
+ download_fn=unexpected_download,
+ )
+
+
+def test_repeat_requires_explicit_opt_in(tmp_path: Path) -> None:
+ lock_path = tmp_path / "lock.json"
+ _write_lock(lock_path)
+
+ def download(**kwargs: Any) -> str:
+ local_dir = Path(kwargs["local_dir"])
+ _populate_snapshot(local_dir, algorithm="sft", row_count=2)
+ return str(local_dir)
+
+ kwargs = {
+ "algorithm": "sft",
+ "profile_name": "sd35-t2i",
+ "world_size": 2,
+ "output_root": tmp_path / "prepared",
+ "lock_path": lock_path,
+ "download_fn": download,
+ }
+ with pytest.raises(ValueError, match="--allow-repeat"):
+ prepare_dataset(**kwargs)
+ prepared = prepare_dataset(**kwargs, allow_repeat=True)
+ assert len((prepared / "train.jsonl").read_text().splitlines()) == 4
+
+
+@pytest.mark.parametrize("failure", ["escape", "identical_pair"])
+def test_invalid_snapshot_never_publishes_partial_output(tmp_path: Path, failure: str) -> None:
+ lock_path = tmp_path / "lock.json"
+ _write_lock(lock_path)
+
+ def download(**kwargs: Any) -> str:
+ local_dir = Path(kwargs["local_dir"])
+ target_path = "../../../escape.png" if failure == "escape" else "../../media/target.png"
+ _populate_snapshot(
+ local_dir,
+ algorithm="offline-dpo",
+ target_path=target_path,
+ identical_pair=failure == "identical_pair",
+ )
+ if failure == "escape":
+ Image.new("RGB", (8, 8)).save(local_dir.parent / "escape.png")
+ return str(local_dir)
+
+ with pytest.raises(ValueError, match="escapes|byte-identical"):
+ prepare_dataset(
+ algorithm="offline-dpo",
+ profile_name="sd35-t2i",
+ world_size=2,
+ output_root=tmp_path / "prepared",
+ lock_path=lock_path,
+ download_fn=download,
+ )
+ assert not (tmp_path / "prepared" / "offline-dpo" / "sd35-t2i").exists()
+
+
+def _all_media(row: Mapping[str, Any]):
+ yield from row["input"].get("media", ())
+ for name in ("target", "chosen", "rejected"):
+ candidate = row["supervision"].get(name)
+ if candidate is not None:
+ yield from candidate["media"]
diff --git a/tests/dataset/test_offline_smoke_profiles.py b/tests/dataset/test_offline_smoke_profiles.py
new file mode 100644
index 000000000..d615cd1b0
--- /dev/null
+++ b/tests/dataset/test_offline_smoke_profiles.py
@@ -0,0 +1,144 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Lock the public offline-smoke task catalog and official I/O contracts."""
+
+import subprocess
+import sys
+from dataclasses import FrozenInstanceError
+from types import SimpleNamespace
+
+import pytest
+
+from dataset.offline_smoke import profiles as p
+from flow_factory import contracts as c
+from flow_factory.models.registry import list_registered_models
+
+PROFILE_IDS = tuple(
+ "text_to_image image_to_image multi_image_to_image text_to_video first_frame_to_video "
+ "first_last_frame_to_video text_to_audio_video first_frame_to_audio_video "
+ "first_last_frame_to_audio_video ordered_references_to_audio_video".split()
+)
+
+
+def _input(profile: str, media_type: c.MediaType) -> c.InputMediaRule:
+ return next(
+ rule
+ for rule in p.CANONICAL_PROFILES[profile].contract.input_media.rules
+ if rule.format.type is media_type
+ )
+
+
+def test_catalog_covers_all_actual_profiles_models_repositories_and_aliases() -> None:
+ assert tuple(p.CANONICAL_PROFILES) == PROFILE_IDS
+ assert {
+ model
+ for profile in p.CANONICAL_PROFILES.values()
+ for model in profile.compatible_model_types
+ } == set(list_registered_models())
+ assert p.SFT_REPO_ID == "Jayce-Ping/Flow-Factory-SFT-Smoke"
+ assert p.OFFLINE_DPO_REPO_ID == "Jayce-Ping/Flow-Factory-Offline-DPO-Smoke"
+ assert set(p.MAIN_GPU_ALIASES) == set(p.GPU_ALIAS_TO_PROFILE) - {"image-i2i"}
+ assert p.SUPPLEMENTAL_GPU_ALIASES == ("image-i2i",)
+
+
+def test_actual_profiles_preserve_exact_output_sequences() -> None:
+ outputs = tuple(p.output_media_types(item.contract) for item in p.CANONICAL_PROFILES.values())
+ assert outputs == (("image",),) * 3 + (("video",),) * 3 + (("video", "audio"),) * 4
+ for profile in tuple(p.CANONICAL_PROFILES.values())[6:]:
+ contract = profile.contract
+ assert contract.output_media.items[0].fps is c.RateRequirement.REQUIRED
+ assert contract.output_media.items[1].sample_rate is c.RateRequirement.REQUIRED
+
+
+def test_image_and_endpoint_profiles_keep_cardinality_order_and_slots() -> None:
+ i2i = _input("image_to_image", c.MediaType.IMAGE)
+ multi = _input("multi_image_to_image", c.MediaType.IMAGE)
+ first_video = _input("first_frame_to_video", c.MediaType.IMAGE)
+ fl_video = _input("first_last_frame_to_video", c.MediaType.IMAGE)
+ first_av = _input("first_frame_to_audio_video", c.MediaType.IMAGE)
+ fl_av = _input("first_last_frame_to_audio_video", c.MediaType.IMAGE)
+
+ assert (i2i.min_count, i2i.max_count) == (1, 1)
+ assert (multi.min_count, multi.max_count) == (2, 2)
+ assert (
+ p.CANONICAL_PROFILES["multi_image_to_image"].contract.input_media.order
+ is c.InputMediaOrder.WITHIN_TYPE
+ )
+ assert (first_video.slots, first_video.required_slots) == (("first_frame",), ("first_frame",))
+ assert (fl_video.slots, fl_video.required_slots) == (
+ ("first_frame", "last_frame"),
+ ("first_frame",),
+ )
+ assert (first_av.slots, first_av.required_slots) == (("first_frame",), ("first_frame",))
+ assert (fl_av.slots, fl_av.required_slots) == (("first_frame", "last_frame"), ())
+
+
+def test_h3_reference_contract_is_global_heterogeneous_and_bounded() -> None:
+ contract = p.CANONICAL_PROFILES["ordered_references_to_audio_video"].contract
+ assert contract.input_media.binding is c.InputMediaBinding.ORDERED_REFERENCES
+ assert contract.input_media.order is c.InputMediaOrder.GLOBAL
+ assert (contract.input_media.min_total_count, contract.input_media.max_total_count) == (1, 12)
+ assert contract.input_media.required_any_types == (c.MediaType.IMAGE, c.MediaType.VIDEO)
+ counts = tuple(
+ (rule.format.type.value, rule.min_count, rule.max_count)
+ for rule in contract.input_media.rules
+ )
+ assert counts == (("image", 0, 9), ("video", 0, 3), ("audio", 0, 3))
+
+
+def test_gpu_variants_keep_model_specific_clocks() -> None:
+ cases = {
+ case.alias: case for profile in p.CANONICAL_PROFILES.values() for case in profile.gpu_cases
+ }
+ wan, ltx, h3 = (cases[name].geometry for name in ("wan-t2v", "ltx2-t2av", "h3-t2va"))
+ assert (wan.height, wan.num_frames) == (240, 5)
+ assert (ltx.height, ltx.width, ltx.num_frames, ltx.sample_rate) == (128, 192, 9, 16000)
+ assert (h3.height, h3.width, h3.num_frames, h3.sample_rate) == (64, 96, 124, 32000)
+ assert p.get_profile("image-i2i") is p.CANONICAL_PROFILES["image_to_image"]
+
+
+def test_official_contract_supports_ordered_heterogeneous_input_and_audio_only_output() -> None:
+ rate = c.RateRequirement
+ video = c.MediaFormat(c.MediaType.VIDEO, rate.OPTIONAL, rate.NOT_APPLICABLE)
+ audio_input = c.MediaFormat(c.MediaType.AUDIO, rate.NOT_APPLICABLE, rate.OPTIONAL)
+ audio_output = c.MediaFormat(c.MediaType.AUDIO, rate.NOT_APPLICABLE, rate.REQUIRED)
+ rules = (c.InputMediaRule(video, 1, 2), c.InputMediaRule(audio_input, 1, 2))
+ inputs = c.InputMediaSpec(
+ rules, c.InputMediaBinding.ORDERED_REFERENCES, c.InputMediaOrder.GLOBAL, 2, 4
+ )
+ contract = c.PipelineIOContract(
+ inputs,
+ c.NegativePromptPolicy.UNSUPPORTED,
+ c.OutputMediaSequence((audio_output,)),
+ c.GeometrySource.OUTPUT_MEDIA,
+ c.BatchCapability.UNIFORM,
+ )
+ media = (
+ SimpleNamespace(type="video", fps=24.0, sample_rate=None),
+ SimpleNamespace(type="audio", fps=None, sample_rate=16000),
+ )
+ model_input = SimpleNamespace(prompt="Compose a sound.", negative_prompt=None, media=media)
+ c.validate_pipeline_model_input(model_input, contract)
+ assert p.output_media_types(contract) == ("audio",)
+ output = SimpleNamespace(type="audio", fps=None, sample_rate=16000)
+ c.validate_pipeline_output_candidate((output,), contract)
+ assert "audio_only" not in p.CANONICAL_PROFILES
+
+
+def test_catalog_is_frozen_and_import_does_not_load_bagel_adapter() -> None:
+ with pytest.raises(FrozenInstanceError):
+ p.CANONICAL_PROFILES["text_to_image"].name = "changed"
+ script = "import sys; import dataset.offline_smoke.profiles; assert 'flow_factory.models.bagel.bagel' not in sys.modules"
+ subprocess.run([sys.executable, "-c", script], check=True)
diff --git a/tests/dataset/test_offline_smoke_publish.py b/tests/dataset/test_offline_smoke_publish.py
new file mode 100644
index 000000000..ac2237374
--- /dev/null
+++ b/tests/dataset/test_offline_smoke_publish.py
@@ -0,0 +1,98 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from dataset.offline_smoke import publish
+from dataset.offline_smoke.profiles import SFT_REPO_ID
+
+
+def _staging_tree(
+ root: Path,
+ *,
+ repository_id: str = SFT_REPO_ID,
+ supervision_type: str = "demonstration",
+) -> Path:
+ for directory in ("media", "profiles/sd35-t2i"):
+ (root / directory).mkdir(parents=True, exist_ok=True)
+ for filename in ("README.md", "LICENSE", "provenance.jsonl"):
+ (root / filename).write_text("fixture\n", encoding="utf-8")
+ (root / "dataset_manifest.json").write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "flow_factory_schema_version": 2,
+ "repository_id": repository_id,
+ "supervision_type": supervision_type,
+ "runtime_aliases": ["sd35-t2i"],
+ }
+ ),
+ encoding="utf-8",
+ )
+ return root
+
+
+def test_wrong_supervision_fails_before_any_hub_mutation(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ staging = _staging_tree(
+ tmp_path / "dpo-staging",
+ repository_id="Jayce-Ping/Flow-Factory-Offline-DPO-Smoke",
+ supervision_type="preference",
+ )
+
+ def unexpected_api() -> None:
+ raise AssertionError("HfApi must not be constructed for mismatched staging")
+
+ monkeypatch.setattr(publish, "HfApi", unexpected_api)
+ with pytest.raises(ValueError, match="requires 'demonstration' supervision"):
+ publish.publish_staging_tree(
+ algorithm="sft",
+ staging_dir=staging,
+ commit_message="must not publish",
+ confirm_public=True,
+ )
+
+
+def test_valid_staging_binds_default_repo_and_returns_commit(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ staging = _staging_tree(tmp_path / "sft-staging")
+ calls = []
+
+ class FakeApi:
+ def create_repo(self, **kwargs) -> None:
+ calls.append(("create_repo", kwargs))
+
+ def upload_folder(self, **kwargs):
+ calls.append(("upload_folder", kwargs))
+ return SimpleNamespace(oid="b" * 40)
+
+ monkeypatch.setattr(publish, "HfApi", FakeApi)
+ revision = publish.publish_staging_tree(
+ algorithm="sft",
+ staging_dir=staging,
+ commit_message="publish test fixture",
+ confirm_public=True,
+ )
+
+ assert revision == "b" * 40
+ assert [name for name, _ in calls] == ["create_repo", "upload_folder"]
+ assert all(kwargs["repo_id"] == SFT_REPO_ID for _, kwargs in calls)
From 0e098eb9d81b957a9ddea46b06ca56b6d8278389 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 18:47:35 +0800
Subject: [PATCH 33/76] [samples,reward] fix: preserve reconstruction fields in
distributed rewards
---
.agents/knowledge/topics/fix_patterns.md | 8 +++
src/flow_factory/rewards/reward_processor.py | 5 ++
src/flow_factory/samples/samples.py | 9 ++-
.../test_reward_processor_reconstruction.py | 57 +++++++++++++++++++
4 files changed, 77 insertions(+), 2 deletions(-)
create mode 100644 tests/rewards/test_reward_processor_reconstruction.py
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 2223bed35..a3a7387c1 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -54,6 +54,14 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Lesson**: When extending a base contract for a partial-coverage feature (where only some subclasses will participate), no-op default + opt-in override beats forcing every subclass to acknowledge it. Reserve `@abstractmethod` for invariants that ALL subclasses must implement (e.g. `load_pipeline`, `decode_latents`, `forward`, `inference`).
- **Related Constraint**: #12 (post-update text codifies "Optional encoder overrides (no-op default)").
+### Distributed reward gathering must preserve reconstruction invariants
+- **Date**: 2026-08-30
+- **Symptom**: Two-rank H3 Ref2VA GRPO failed before reward execution because `MiniMaxH3Ref2VASample` was reconstructed with `reference_manifest=None`.
+- **Root Cause**: The distributed group-reward path gathered only reward-consumed fields, although `gather_samples` reconstructs the concrete sample class and that class can require additional state.
+- **Fix**: `BaseSample` now declares an empty `reconstruction_required_fields` contract, `OrderedReferenceConditionSample` adds `reference_manifest`, and `RewardProcessor` unions that contract into its distributed gather fields without forwarding it to the reward call.
+- **Lesson**: Communication payload requirements and reward-call requirements are distinct contracts; partial gathers must preserve constructor invariants even for fields that downstream computation does not consume.
+- **Related Constraint**: N/A
+
### Preference-arm conditioning ownership
- **Date**: 2026-08-11
- **Symptom**: DPO evaluated the rejected H3 state with the chosen sample's prompt/reference conditioning.
diff --git a/src/flow_factory/rewards/reward_processor.py b/src/flow_factory/rewards/reward_processor.py
index ccd000867..8632ff06c 100644
--- a/src/flow_factory/rewards/reward_processor.py
+++ b/src/flow_factory/rewards/reward_processor.py
@@ -474,6 +474,11 @@ def _compute_groupwise_distributed(
for model in models.values():
required_fields.update(model.required_fields)
+ # ``gather_samples`` reconstructs the concrete sample class from the
+ # transported fields. Preserve fields required by that class's constructor
+ # invariants even when the reward itself does not consume them.
+ required_fields.update(type(samples[0]).reconstruction_required_fields)
+
# Always include the typed source bookkeeping — the gate needs
# `source` (and ideally `source_id`) on the gathered side. Now
# that they're real dataclass fields on `BaseSample`, gathering
diff --git a/src/flow_factory/samples/samples.py b/src/flow_factory/samples/samples.py
index 71c914fa7..86d2e7dd0 100644
--- a/src/flow_factory/samples/samples.py
+++ b/src/flow_factory/samples/samples.py
@@ -97,6 +97,10 @@ class BaseSample:
{"height", "width", "latent_index_map", "log_prob_index_map"}
)
+ # Fields that must be transported whenever a concrete sample is reconstructed
+ # from a partial cross-rank gather, even when no downstream consumer reads them.
+ reconstruction_required_fields: ClassVar[frozenset[str]] = frozenset()
+
# Denoiseing trajectory
timesteps: Optional[torch.Tensor] = None # (T+1,)
all_latents: Optional[torch.Tensor] = None # (num_steps, Seq_len, C)
@@ -633,8 +637,9 @@ class T2AVSample(BaseSample):
class OrderedReferenceConditionSample(BaseSample):
"""Sample conditioned by an ordered heterogeneous reference manifest."""
- _id_fields: ClassVar[frozenset[str]] = BaseSample._id_fields | frozenset(
- {"reference_manifest"}
+ _id_fields: ClassVar[frozenset[str]] = BaseSample._id_fields | frozenset({"reference_manifest"})
+ reconstruction_required_fields: ClassVar[frozenset[str]] = (
+ BaseSample.reconstruction_required_fields | frozenset({"reference_manifest"})
)
reference_manifest: Optional[str] = None
diff --git a/tests/rewards/test_reward_processor_reconstruction.py b/tests/rewards/test_reward_processor_reconstruction.py
new file mode 100644
index 000000000..f9fd45574
--- /dev/null
+++ b/tests/rewards/test_reward_processor_reconstruction.py
@@ -0,0 +1,57 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from types import SimpleNamespace
+
+import torch
+
+from flow_factory.rewards import GroupwiseRewardModel, RewardModelOutput
+from flow_factory.rewards.reward_processor import RewardProcessor
+from flow_factory.samples import MiniMaxH3Ref2VASample
+
+
+class PromptOnlyGroupReward(GroupwiseRewardModel):
+ required_fields = ("prompt",)
+
+ def __init__(self) -> None:
+ pass
+
+ def __call__(self, prompt: list[str]) -> RewardModelOutput:
+ return RewardModelOutput(rewards=torch.zeros(len(prompt)))
+
+
+def test_distributed_group_reward_preserves_sample_reconstruction_fields() -> None:
+ accelerator = SimpleNamespace(
+ device=torch.device("cpu"),
+ process_index=0,
+ num_processes=1,
+ is_local_main_process=True,
+ wait_for_everyone=lambda: None,
+ reduce=lambda tensor, reduction: tensor,
+ )
+ model = PromptOnlyGroupReward()
+ processor = RewardProcessor(
+ accelerator=accelerator,
+ reward_models={"prompt_only": model},
+ group_on_same_rank=False,
+ verbose=False,
+ )
+ sample = MiniMaxH3Ref2VASample(
+ prompt="A reference-conditioned prompt",
+ reference_manifest='[{"kind":"image","path":"condition.png"}]',
+ )
+
+ rewards = processor.compute_rewards([sample], store_to_samples=False)
+
+ torch.testing.assert_close(rewards["prompt_only"], torch.zeros(1))
From 527092d78d42b64b07d62f18f2e1242e0231cfc8 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 19:12:18 +0800
Subject: [PATCH 34/76] [trainer] fix: preserve ZeRO logical optimizer identity
---
.agents/knowledge/topics/fix_patterns.md | 14 +++
.../trainers/common/runtime_identity.py | 48 ++++++++--
tests/trainers/test_runtime_identity.py | 92 +++++++++++++++++++
3 files changed, 146 insertions(+), 8 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index a3a7387c1..e6747a3e7 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -333,6 +333,20 @@ Based on the fix type, write the fix entry to the appropriate document:
and set-like identity fields require one canonical representation.
- **Related Constraint**: #5
+### ZeRO optimizer identity must use logical model groups
+- **Date**: 2026-08-30
+- **Symptom**: Every ZeRO-2 trainer failed during initialization because its optimizer schema
+ contained a parameter not owned by the rebound component-variant registry.
+- **Root Cause**: DeepSpeed ZeRO-1/2 replaces each public optimizer group with a rank-local flat
+ FP32 master partition, while runtime identity incorrectly treated those partitions as the live
+ model parameters owned by the registry.
+- **Fix**: Runtime identity now maps stable parameter ownership through DeepSpeed's retained
+ `bit16_groups` and continues to serialize settings from the public optimizer groups. It fails
+ closed if logical groups are absent or do not match the partitioned group count.
+- **Lesson**: A distributed optimizer's public parameter groups may be physical state partitions;
+ exact-resume identity must separate logical model ownership from physical group settings.
+- **Related Constraint**: #18a
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/trainers/common/runtime_identity.py b/src/flow_factory/trainers/common/runtime_identity.py
index 356df6f9d..5fc6148e2 100644
--- a/src/flow_factory/trainers/common/runtime_identity.py
+++ b/src/flow_factory/trainers/common/runtime_identity.py
@@ -25,6 +25,7 @@
from typing import Any
import torch
+from accelerate.utils import DistributedType
from torch.utils.data import ConcatDataset, DataLoader, Subset
_EXECUTION_IDENTITY_HOOK = "runtime_execution_identity_payload"
@@ -930,10 +931,13 @@ def _parameter_schema(trainer: Any) -> tuple[list[dict[str, Any]], dict[int, str
def _optimizer_schema(trainer: Any, parameter_keys: Mapping[int, str]) -> dict[str, Any]:
"""Return ordered optimizer groups linked to the stable parameter schema."""
optimizer = trainer.optimizer
+ optimizer_groups = optimizer.param_groups
+ logical_parameter_groups = _logical_optimizer_parameter_groups(trainer, optimizer_groups)
groups = []
- consumed_parameters: set[int] = set()
- for group_index, group in enumerate(optimizer.param_groups):
- raw_parameters = group.get("params")
+ consumed_parameters: set[str] = set()
+ for group_index, (group, raw_parameters) in enumerate(
+ zip(optimizer_groups, logical_parameter_groups)
+ ):
if not isinstance(raw_parameters, Sequence):
raise TypeError(
f"optimizer group {group_index} params must be a sequence, "
@@ -947,9 +951,9 @@ def _optimizer_schema(trainer: Any, parameter_keys: Mapping[int, str]) -> dict[s
"optimizer schema contains a parameter not owned by the rebound "
f"variant registry at group {group_index}, index {parameter_index}"
)
- if id(parameter) in consumed_parameters:
+ if key in consumed_parameters:
raise ValueError(f"optimizer schema references parameter {key!r} more than once")
- consumed_parameters.add(id(parameter))
+ consumed_parameters.add(key)
group_parameters.append(key)
settings = {}
for key, value in group.items():
@@ -970,11 +974,11 @@ def _optimizer_schema(trainer: Any, parameter_keys: Mapping[int, str]) -> dict[s
"settings": settings,
}
)
- missing_parameters = frozenset(parameter_keys).difference(consumed_parameters)
+ missing_parameters = frozenset(parameter_keys.values()).difference(consumed_parameters)
if missing_parameters:
- missing_keys = tuple(parameter_keys[identity] for identity in missing_parameters)
raise ValueError(
- "optimizer schema does not exhaust rebound variant parameters: " f"{missing_keys!r}"
+ "optimizer schema does not exhaust rebound variant parameters: "
+ f"{tuple(sorted(missing_parameters))!r}"
)
return {
"type_chain": _optimizer_type_chain(optimizer),
@@ -982,6 +986,34 @@ def _optimizer_schema(trainer: Any, parameter_keys: Mapping[int, str]) -> dict[s
}
+def _logical_optimizer_parameter_groups(
+ trainer: Any,
+ optimizer_groups: Sequence[Mapping[str, Any]],
+) -> Sequence[Sequence[torch.Tensor]]:
+ """Return model-owned parameters before ZeRO replaces groups with flat partitions."""
+ accelerator = trainer.accelerator
+ deepspeed_plugin = getattr(getattr(accelerator, "state", None), "deepspeed_plugin", None)
+ if getattr(accelerator, "distributed_type", None) != DistributedType.DEEPSPEED or getattr(
+ deepspeed_plugin, "zero_stage", None
+ ) not in (1, 2):
+ return tuple(group.get("params") for group in optimizer_groups)
+
+ deepspeed_optimizer = getattr(trainer.optimizer, "optimizer", None)
+ logical_groups = getattr(deepspeed_optimizer, "bit16_groups", None)
+ if not isinstance(logical_groups, Sequence) or isinstance(logical_groups, (str, bytes)):
+ raise TypeError(
+ "DeepSpeed ZeRO-1/2 optimizer schema requires the logical model parameter "
+ f"groups from optimizer.bit16_groups, received {type(logical_groups).__name__}: "
+ f"{logical_groups!r}"
+ )
+ if len(logical_groups) != len(optimizer_groups):
+ raise ValueError(
+ "DeepSpeed ZeRO-1/2 optimizer schema expected logical and partitioned group "
+ f"counts to match, received {len(logical_groups)} and {len(optimizer_groups)}"
+ )
+ return logical_groups
+
+
def _optimizer_type_chain(optimizer: Any) -> list[str]:
"""Describe transparent optimizer wrappers without following cycles."""
names = []
diff --git a/tests/trainers/test_runtime_identity.py b/tests/trainers/test_runtime_identity.py
index 7350e667a..f1b368b0f 100644
--- a/tests/trainers/test_runtime_identity.py
+++ b/tests/trainers/test_runtime_identity.py
@@ -362,6 +362,95 @@ def test_optimizer_schema_rejects_parameters_outside_rebound_registry() -> None:
build_trainer_runtime_identity(trainer)
+@pytest.mark.parametrize("zero_stage", (1, 2))
+def test_deepspeed_optimizer_schema_maps_flat_partitions_through_logical_groups(
+ zero_stage: int,
+) -> None:
+ """ZeRO flat optimizer partitions retain their logical variant ownership."""
+
+ def identity(partition_widths: tuple[int, int]) -> dict[str, Any]:
+ trainer = _Trainer()
+ fake_parameter = torch.nn.Parameter(torch.ones(3, 3))
+ trainer.adapter.component_variant_registry.records["fake"] = (
+ _Record("transformer", "fake_weight", fake_parameter),
+ )
+ trainer.optimizer.add_param_group(
+ {"params": [fake_parameter], "role_name": "fake", "lr": 2e-3}
+ )
+ trainer.config.optimizer_args = MultiOptimizerArguments(
+ optimizer_configs=[
+ AdamWOptimizerArguments(name="base", learning_rate=1e-3),
+ AdamWOptimizerArguments(name="fake", learning_rate=2e-3),
+ ]
+ )
+ trainer._required_trainable_roles = lambda: ("base", "fake")
+
+ basic_optimizer = trainer.optimizer
+ logical_groups = [list(group["params"]) for group in basic_optimizer.param_groups]
+ for group, width in zip(basic_optimizer.param_groups, partition_widths):
+ group["params"] = [torch.nn.Parameter(torch.zeros(width), requires_grad=True)]
+ zero_optimizer = SimpleNamespace(
+ bit16_groups=logical_groups,
+ optimizer=basic_optimizer,
+ )
+ trainer.optimizer = SimpleNamespace(
+ param_groups=basic_optimizer.param_groups,
+ optimizer=zero_optimizer,
+ )
+ trainer.accelerator.distributed_type = DistributedType.DEEPSPEED
+ trainer.accelerator.state.deepspeed_plugin = SimpleNamespace(
+ zero_stage=zero_stage,
+ deepspeed_config={"zero_optimization": {"stage": zero_stage}},
+ gradient_accumulation_steps=1,
+ gradient_clipping=1.0,
+ is_train_batch_min=True,
+ )
+ return build_trainer_runtime_identity(trainer)
+
+ rank_zero = identity((5, 7))
+ rank_one = identity((6, 8))
+
+ assert rank_zero["parameter_schema_digest"] == rank_one["parameter_schema_digest"]
+ assert rank_zero["optimizer_schema_digest"] == rank_one["optimizer_schema_digest"]
+
+
+def test_deepspeed_optimizer_schema_rejects_foreign_logical_parameters() -> None:
+ """ZeRO logical groups must still exhaust only registry-owned parameters."""
+ trainer = _Trainer()
+ trainer.accelerator.distributed_type = DistributedType.DEEPSPEED
+ trainer.accelerator.state.deepspeed_plugin = SimpleNamespace(zero_stage=2)
+ trainer.optimizer.optimizer = SimpleNamespace(
+ bit16_groups=[[torch.nn.Parameter(torch.zeros(1))]]
+ )
+
+ with pytest.raises(ValueError, match="not owned by the rebound variant registry"):
+ build_trainer_runtime_identity(trainer)
+
+
+@pytest.mark.parametrize(
+ ("logical_groups", "error_type", "message"),
+ (
+ (None, TypeError, "requires the logical model parameter groups"),
+ ([], ValueError, "logical and partitioned group counts to match"),
+ ),
+)
+def test_deepspeed_optimizer_schema_rejects_invalid_logical_groups(
+ logical_groups: list[list[torch.nn.Parameter]] | None,
+ error_type: type[Exception],
+ message: str,
+) -> None:
+ """ZeRO identity fails closed when its logical parameter seam is unavailable."""
+ trainer = _Trainer()
+ trainer.accelerator.distributed_type = DistributedType.DEEPSPEED
+ trainer.accelerator.state.deepspeed_plugin = SimpleNamespace(zero_stage=2)
+ trainer.optimizer.optimizer = SimpleNamespace()
+ if logical_groups is not None:
+ trainer.optimizer.optimizer.bit16_groups = logical_groups
+
+ with pytest.raises(error_type, match=message):
+ build_trainer_runtime_identity(trainer)
+
+
def test_backend_and_precision_drift_change_the_resume_identity() -> None:
"""Backend checkpoint layouts are rejected before prepared-state mutation."""
baseline_trainer = _Trainer()
@@ -457,6 +546,9 @@ def identity(*, micro_batch: int, accumulation_steps: int) -> dict[str, Any]:
gradient_clipping="auto",
is_train_batch_min=True,
)
+ trainer.optimizer.optimizer = SimpleNamespace(
+ bit16_groups=[list(group["params"]) for group in trainer.optimizer.param_groups]
+ )
return build_trainer_runtime_identity(trainer)
baseline = identity(micro_batch=1, accumulation_steps=2)
From e570d1430d32daf87b43f5d8600a63b2c9eea3b4 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 19:26:39 +0800
Subject: [PATCH 35/76] [models] fix: enable Bagel gradient checkpointing
---
.agents/knowledge/topics/fix_patterns.md | 16 +++
.../bagel/modeling/bagel/qwen2_navit.py | 34 ++++--
tests/models/test_bagel_tdm_contracts.py | 112 ++++++++++++++++++
3 files changed, 152 insertions(+), 10 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index e6747a3e7..ca4c4b76f 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -347,6 +347,22 @@ Based on the fix type, write the fix entry to the appropriate document:
exact-resume identity must separate logical model ownership from physical group settings.
- **Related Constraint**: #18a
+### Custom Transformers models must implement their declared checkpointing seam
+- **Date**: 2026-08-30
+- **Symptom**: Every Bagel trainer failed during initialization when full gradient checkpointing
+ called `Qwen2ForCausalLM.gradient_checkpointing_enable()` and Transformers reported that the
+ architecture was incompatible.
+- **Root Cause**: Bagel's custom Qwen2-NaViT model inherited
+ `supports_gradient_checkpointing = True` but did not expose a `gradient_checkpointing` state or
+ invoke the installed checkpoint function in its active decoder loop.
+- **Fix**: The custom Qwen2 model now owns the standard checkpointing flag and routes each pure
+ decoder layer through Transformers' installed non-reentrant checkpoint function while training.
+ Cache-updating and TaylorSeer paths remain direct to avoid replaying mutations. A backward
+ regression proves the layer is recomputed rather than merely accepting the API call.
+- **Lesson**: A custom `PreTrainedModel` must pair its capability declaration with both the state
+ seam expected by Transformers and an execution-time checkpoint boundary in the forward path.
+- **Related Constraint**: #7
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
index aa4f172fb..43e13ee6c 100644
--- a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
+++ b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
@@ -1083,6 +1083,7 @@ def __init__(self, config):
if self.use_moe:
self.norm_moe_gen = Qwen2RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.rotary_emb = Qwen2RotaryEmbedding(config=config)
+ self.gradient_checkpointing = False
# Initialize weights and apply final processing
self.post_init()
@@ -1194,18 +1195,31 @@ def forward_inference(
decoder_layer.cache_dic = self.cache_dic
decoder_layer.enable_taylorseer = True
self.current["layer"] = layer_idx
- packed_query_sequence, past_key_values = decoder_layer(
- packed_query_sequence=packed_query_sequence,
- query_lens=query_lens,
- packed_query_position_embeddings=packed_query_position_embeddings,
- packed_query_indexes=packed_query_indexes,
- past_key_values=past_key_values,
- key_values_lens=key_values_lens,
- packed_key_value_indexes=packed_key_value_indexes,
- update_past_key_values=update_past_key_values,
- is_causal=is_causal,
+ layer_kwargs = {
+ "packed_query_sequence": packed_query_sequence,
+ "query_lens": query_lens,
+ "packed_query_position_embeddings": packed_query_position_embeddings,
+ "packed_query_indexes": packed_query_indexes,
+ "past_key_values": past_key_values,
+ "key_values_lens": key_values_lens,
+ "packed_key_value_indexes": packed_key_value_indexes,
+ "update_past_key_values": update_past_key_values,
+ "is_causal": is_causal,
**extra_inputs,
+ }
+ checkpoint_layer = (
+ self.gradient_checkpointing
+ and self.training
+ and not update_past_key_values
+ and not enable_taylorseer
)
+ if checkpoint_layer:
+ packed_query_sequence, past_key_values = self._gradient_checkpointing_func(
+ decoder_layer.__call__,
+ **layer_kwargs,
+ )
+ else:
+ packed_query_sequence, past_key_values = decoder_layer(**layer_kwargs)
if self.use_moe:
if mode == "und":
diff --git a/tests/models/test_bagel_tdm_contracts.py b/tests/models/test_bagel_tdm_contracts.py
index 949359ea5..bb5c01a3a 100644
--- a/tests/models/test_bagel_tdm_contracts.py
+++ b/tests/models/test_bagel_tdm_contracts.py
@@ -48,6 +48,70 @@ def _load_bagel_types(monkeypatch: pytest.MonkeyPatch) -> tuple[type, type]:
return module.BagelAdapter, module.BagelSample
+def _load_bagel_qwen_types(monkeypatch: pytest.MonkeyPatch) -> tuple[type, type]:
+ """Load Bagel's Qwen2-NaViT model behind the optional-kernel seam."""
+ _load_bagel_types(monkeypatch)
+ module = importlib.import_module("flow_factory.models.bagel.modeling.bagel.qwen2_navit")
+ return module.Qwen2Config, module.Qwen2ForCausalLM
+
+
+class _FakeRotaryEmbedding(torch.nn.Module):
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ position_ids: torch.Tensor,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ del position_ids
+ values = torch.ones_like(hidden_states).unsqueeze(0)
+ return values, values
+
+
+class _CheckpointedDecoderLayer(torch.nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.calls = 0
+
+ def forward(
+ self,
+ *,
+ packed_query_sequence: torch.Tensor,
+ past_key_values: Any,
+ **kwargs: Any,
+ ) -> tuple[torch.Tensor, Any]:
+ del kwargs
+ self.calls += 1
+ return packed_query_sequence.square(), past_key_values
+
+
+def _checkpointing_qwen_model(
+ monkeypatch: pytest.MonkeyPatch,
+) -> tuple[torch.nn.Module, _CheckpointedDecoderLayer]:
+ qwen_config_type, qwen_causal_lm_type = _load_bagel_qwen_types(monkeypatch)
+ config = qwen_config_type(
+ vocab_size=8,
+ hidden_size=8,
+ intermediate_size=16,
+ num_hidden_layers=1,
+ num_attention_heads=2,
+ num_key_value_heads=2,
+ max_position_embeddings=16,
+ layer_module="Qwen2DecoderLayer",
+ qk_norm=False,
+ _attn_implementation="eager",
+ pad_token_id=0,
+ )
+ causal_lm = qwen_causal_lm_type(config)
+ model = causal_lm.model
+ decoder = _CheckpointedDecoderLayer()
+ model.layers = torch.nn.ModuleList([decoder])
+ model.rotary_emb = _FakeRotaryEmbedding()
+ model.norm = torch.nn.Identity()
+ model.use_moe = False
+ causal_lm.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
+ model.train()
+ return model, decoder
+
+
def _adapter(adapter_type: type, decoder: Any) -> Any:
adapter = object.__new__(adapter_type)
adapter.decode_latents = MethodType(decoder, adapter)
@@ -69,6 +133,54 @@ def _result(batch_size: int = 2) -> dict[str, Any]:
}
+def test_bagel_qwen_gradient_checkpointing_recomputes_decoder(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ model, decoder = _checkpointing_qwen_model(monkeypatch)
+
+ packed = torch.tensor([[2.0, 3.0]], requires_grad=True)
+ cache = object()
+ output = model.forward_inference(
+ packed_query_sequence=packed,
+ query_lens=torch.tensor([1]),
+ packed_query_position_ids=torch.tensor([0]),
+ packed_query_indexes=torch.tensor([0]),
+ past_key_values=cache,
+ key_values_lens=torch.tensor([0]),
+ packed_key_value_indexes=torch.tensor([], dtype=torch.long),
+ update_past_key_values=False,
+ is_causal=False,
+ )
+ output.packed_query_sequence.sum().backward()
+
+ assert model.gradient_checkpointing is True
+ assert decoder.calls == 2
+ assert output.past_key_values is cache
+ torch.testing.assert_close(packed.grad, torch.tensor([[4.0, 6.0]]))
+
+
+def test_bagel_qwen_checkpointing_does_not_replay_cache_updates(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ model, decoder = _checkpointing_qwen_model(monkeypatch)
+
+ packed = torch.tensor([[2.0, 3.0]], requires_grad=True)
+ output = model.forward_inference(
+ packed_query_sequence=packed,
+ query_lens=torch.tensor([1]),
+ packed_query_position_ids=torch.tensor([0]),
+ packed_query_indexes=torch.tensor([0]),
+ past_key_values=object(),
+ key_values_lens=torch.tensor([0]),
+ packed_key_value_indexes=torch.tensor([], dtype=torch.long),
+ update_past_key_values=True,
+ is_causal=False,
+ )
+ output.packed_query_sequence.sum().backward()
+
+ assert decoder.calls == 1
+
+
def test_bagel_assembles_samples_from_one_batched_decode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From 05c1d592fadd1ea1088ee601e1eee79fc16caf73 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 19:39:10 +0800
Subject: [PATCH 36/76] [models] fix: align Bagel FSDP wrap classes
---
.agents/knowledge/topics/fix_patterns.md | 13 ++++++
.../bagel/modeling/bagel/qwen2_navit.py | 2 +
tests/models/test_bagel_tdm_contracts.py | 45 +++++++++++++++++++
3 files changed, 60 insertions(+)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index ca4c4b76f..a21928655 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -363,6 +363,19 @@ Based on the fix type, write the fix entry to the appropriate document:
seam expected by Transformers and an execution-time checkpoint boundary in the forward path.
- **Related Constraint**: #7
+### FSDP wrap metadata must follow the instantiated architecture variant
+- **Date**: 2026-08-30
+- **Symptom**: Every Bagel FSDP2 trainer failed during `accelerator.prepare()` because Accelerate
+ could not find the declared `Qwen2DecoderLayer` in the loaded model.
+- **Root Cause**: Bagel's custom Qwen2 classes inherited fixed `_no_split_modules` metadata from the
+ standard decoder even though `config.layer_module` instantiated a MoE or MoT decoder variant.
+- **Fix**: Both the inner Qwen2 model and outer causal LM now derive their no-split class from the
+ realized `layer_module`. CPU FSDP2 auto-wrap regressions verify all decoder variants resolve
+ through the same PEFT wrapper used by LoRA training.
+- **Lesson**: Distributed wrap metadata is realized model state. Config-selectable architectures
+ must not inherit a fixed block class that may be absent from the instantiated module tree.
+- **Related Constraint**: #9
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
index 43e13ee6c..d81e96f3c 100644
--- a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
+++ b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
@@ -1069,6 +1069,7 @@ def forward_inference(
class Qwen2Model(Qwen2PreTrainedModel):
def __init__(self, config):
super().__init__(config)
+ self._no_split_modules = [config.layer_module]
self.padding_idx = config.pad_token_id
self.vocab_size = config.vocab_size
self.use_moe = "Mo" in config.layer_module
@@ -1250,6 +1251,7 @@ class Qwen2ForCausalLM(Qwen2PreTrainedModel):
def __init__(self, config):
super().__init__(config)
+ self._no_split_modules = [config.layer_module]
self.model = Qwen2Model(config)
self.vocab_size = config.vocab_size
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
diff --git a/tests/models/test_bagel_tdm_contracts.py b/tests/models/test_bagel_tdm_contracts.py
index bb5c01a3a..9d6bf1010 100644
--- a/tests/models/test_bagel_tdm_contracts.py
+++ b/tests/models/test_bagel_tdm_contracts.py
@@ -23,8 +23,11 @@
import pytest
import torch
+from accelerate import FullyShardedDataParallelPlugin
+from peft import LoraConfig, get_peft_model
import flow_factory.utils.imports as import_utils
+from flow_factory.models.model_bundle import ModelBundle
from flow_factory.samples import BaseSample
from flow_factory.trainers.distillation.distillation_runtime import (
validate_media_free_rollout,
@@ -181,6 +184,48 @@ def test_bagel_qwen_checkpointing_does_not_replay_cache_updates(
assert decoder.calls == 1
+@pytest.mark.parametrize(
+ "layer_module",
+ ["Qwen2DecoderLayer", "Qwen2MoEDecoderLayer", "Qwen2MoTDecoderLayer"],
+)
+def test_bagel_qwen_fsdp_wrap_policy_matches_decoder_variant(
+ monkeypatch: pytest.MonkeyPatch,
+ layer_module: str,
+) -> None:
+ qwen_config_type, qwen_causal_lm_type = _load_bagel_qwen_types(monkeypatch)
+ config = qwen_config_type(
+ vocab_size=8,
+ hidden_size=8,
+ intermediate_size=16,
+ num_hidden_layers=1,
+ num_attention_heads=2,
+ num_key_value_heads=2,
+ max_position_embeddings=16,
+ layer_module=layer_module,
+ qk_norm=False,
+ _attn_implementation="eager",
+ pad_token_id=0,
+ )
+ base_model = qwen_causal_lm_type(config)
+ decoder_type = type(base_model.model.layers[0])
+ model = get_peft_model(
+ base_model,
+ LoraConfig(r=2, lora_alpha=2, target_modules=["q_proj"]),
+ )
+ bundle = ModelBundle({"transformer": model})
+ plugin = FullyShardedDataParallelPlugin(
+ fsdp_version=2,
+ auto_wrap_policy="transformer_based_wrap",
+ )
+
+ plugin.set_auto_wrap_policy(bundle)
+
+ assert set(model._no_split_modules) == {decoder_type.__name__}
+ assert set(base_model.model._no_split_modules) == {decoder_type.__name__}
+ assert set(bundle._no_split_modules) == {decoder_type.__name__}
+ assert plugin.auto_wrap_policy.keywords["transformer_layer_cls"] == {decoder_type}
+
+
def test_bagel_assembles_samples_from_one_batched_decode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From 95d05b468032b1bd2ccaa557bda86a981c5b468c Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 20:04:08 +0800
Subject: [PATCH 37/76] [models] fix: route Bagel FSDP language model forwards
---
.agents/knowledge/topics/fix_patterns.md | 19 ++
src/flow_factory/models/bagel/bagel.py | 33 ++-
.../models/bagel/modeling/bagel/bagel.py | 40 ++--
.../bagel/modeling/bagel/qwen2_navit.py | 33 ++-
tests/models/test_bagel_tdm_contracts.py | 212 +++++++++++++++++-
5 files changed, 309 insertions(+), 28 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index a21928655..1f2d9efb1 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -376,6 +376,25 @@ Based on the fix type, write the fix entry to the appropriate document:
must not inherit a fixed block class that may be absent from the instantiated module tree.
- **Related Constraint**: #9
+### Parameter-sharded submodule work must remain inside the prepared root forward
+- **Date**: 2026-08-30
+- **Symptom**: Every Bagel FSDP2 trainer reached sampling or offline replay but failed at token
+ embedding with a mixed `torch.Tensor` and `DTensor` operator error.
+- **Root Cause**: Bagel's cache helpers and denoising path reached through the physical pipeline to
+ `language_model.model.embed_tokens` before calling the routed transformer. Decoder layers owned
+ nested FSDP groups, but embedding and final normalization belonged to the prepared `ModelBundle`
+ root, whose unshard hook was bypassed by those direct calls.
+- **Fix**: The outer Qwen forward now accepts raw packed token IDs and inserts their embeddings into
+ an optional query-local auxiliary sequence. Bagel cache helpers accept an injected language-model
+ forward, and the adapter supplies its routed transformer for text, VAE, ViT, denoising, and CFG
+ passes. Each logical language-model pass therefore performs embedding, decoder execution, and
+ final normalization inside one prepared-root call.
+- **Lesson**: Under compositional FSDP, wrapping transformer blocks does not make arbitrary child
+ access safe. Any computation using parameters owned by the prepared root must execute beneath
+ that root's forward hooks; converting ordinary inputs to `DTensor` or manually unsharding only
+ masks the first failure and breaks distributed lifecycle semantics.
+- **Related Constraint**: #9
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/bagel/bagel.py b/src/flow_factory/models/bagel/bagel.py
index 678ec42bd..bef659b2b 100644
--- a/src/flow_factory/models/bagel/bagel.py
+++ b/src/flow_factory/models/bagel/bagel.py
@@ -814,7 +814,9 @@ def _update_context_text(self, text: Union[str, List[str]], gen_context: Dict) -
)
generation_input = move_tensors_to_device(generation_input, device, max_depth=1)
past_key_values = bagel.forward_cache_update_text(
- gen_context["past_key_values"], **generation_input
+ gen_context["past_key_values"],
+ language_model_forward=self.transformer,
+ **generation_input,
)
return {"kv_lens": kv_lens, "ropes": ropes, "past_key_values": past_key_values}
@@ -861,7 +863,10 @@ def _update_context_image(
)
gen_input = move_tensors_to_device(gen_input, device, max_depth=1)
past_key_values = bagel.forward_cache_update_vae(
- vae_model, past_key_values, **gen_input
+ vae_model,
+ past_key_values,
+ language_model_forward=self.transformer,
+ **gen_input,
)
if vit:
@@ -873,7 +878,11 @@ def _update_context_image(
new_token_ids=self.new_token_ids,
)
gen_input = move_tensors_to_device(gen_input, device, max_depth=1)
- past_key_values = bagel.forward_cache_update_vit(past_key_values, **gen_input)
+ past_key_values = bagel.forward_cache_update_vit(
+ past_key_values,
+ language_model_forward=self.transformer,
+ **gen_input,
+ )
return {"kv_lens": kv_lens, "ropes": ropes, "past_key_values": past_key_values}
@@ -951,14 +960,6 @@ def _forward_flow(
cfg_img_packed_key_value_indexes: Optional[torch.LongTensor] = None,
cfg_type: str = "parallel",
):
- packed_text_embedding = self.pipeline.transformer.model.embed_tokens(
- packed_text_ids
- ).float()
- packed_sequence = packed_text_embedding.new_zeros(
- (sum(packed_seqlens), self.pipeline.bagel.hidden_size), dtype=torch.float32
- )
- packed_sequence[packed_text_indexes] = packed_text_embedding
-
# ``x_t`` is the packed VAE-token tensor (sum_vae_tokens, patch_dim). A stray
# leading batch dim of 1 (callers passing (1, tokens, dim)) is squeezed off.
# ``timestep`` is one sigma per VAE token (expanded per sample upstream), so it
@@ -973,6 +974,9 @@ def _forward_flow(
packed_pos_embed = self.pipeline.bagel.latent_pos_embed(packed_vae_position_ids)
packed_timestep_embeds = self.pipeline.bagel.time_embedder(timestep)
x_t = self.pipeline.bagel.vae2llm(x_t) + packed_timestep_embeds + packed_pos_embed
+ packed_sequence = x_t.new_zeros(
+ (sum(packed_seqlens), self.pipeline.bagel.hidden_size), dtype=torch.float32
+ )
if x_t.dtype != packed_sequence.dtype:
x_t = x_t.to(packed_sequence.dtype)
packed_sequence[packed_vae_token_indexes] = x_t
@@ -982,7 +986,6 @@ def _forward_flow(
extra_inputs = {
"mode": "gen",
"packed_vae_token_indexes": packed_vae_token_indexes,
- "packed_text_indexes": packed_text_indexes,
}
output = self.transformer(
packed_query_sequence=packed_sequence,
@@ -994,6 +997,8 @@ def _forward_flow(
packed_key_value_indexes=packed_key_value_indexes,
update_past_key_values=False,
is_causal=False,
+ packed_text_ids=packed_text_ids,
+ packed_text_indexes=packed_text_indexes,
**extra_inputs,
)
v_t = self.pipeline.bagel.llm2vae(output.packed_query_sequence)
@@ -1009,6 +1014,8 @@ def _forward_flow(
packed_key_value_indexes=cfg_text_packed_key_value_indexes,
update_past_key_values=False,
is_causal=False,
+ packed_text_ids=packed_text_ids,
+ packed_text_indexes=packed_text_indexes,
**extra_inputs,
)
cfg_text_v_t = self.pipeline.bagel.llm2vae(cfg_text_output.packed_query_sequence)
@@ -1024,6 +1031,8 @@ def _forward_flow(
packed_key_value_indexes=cfg_img_packed_key_value_indexes,
update_past_key_values=False,
is_causal=False,
+ packed_text_ids=packed_text_ids,
+ packed_text_indexes=packed_text_indexes,
**extra_inputs,
)
cfg_img_v_t = self.pipeline.bagel.llm2vae(cfg_img_output.packed_query_sequence)
diff --git a/src/flow_factory/models/bagel/modeling/bagel/bagel.py b/src/flow_factory/models/bagel/modeling/bagel/bagel.py
index e7eedf3a1..06286fa99 100644
--- a/src/flow_factory/models/bagel/modeling/bagel/bagel.py
+++ b/src/flow_factory/models/bagel/modeling/bagel/bagel.py
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
import copy
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple
import torch
import torch.nn.functional as F
@@ -299,15 +299,19 @@ def forward_cache_update_text(
packed_text_indexes: torch.LongTensor,
packed_key_value_indexes: torch.LongTensor,
key_values_lens: torch.IntTensor,
+ language_model_forward: Optional[Callable[..., Any]] = None,
):
- packed_text_embedding = self.language_model.model.embed_tokens(packed_text_ids)
+ # Flow-Factory injects its prepared component route here. Standalone Bagel
+ # keeps the original physical language-model call as the default.
+ if language_model_forward is None:
+ language_model_forward = self.language_model
extra_inputs = {}
if self.use_moe:
extra_inputs = {"mode": "und"}
- output = self.language_model.forward_inference(
- packed_query_sequence=packed_text_embedding,
+ output = language_model_forward(
+ packed_query_sequence=None,
query_lens=text_token_lens,
packed_query_position_ids=packed_text_position_ids,
packed_query_indexes=packed_text_indexes,
@@ -316,6 +320,7 @@ def forward_cache_update_text(
key_values_lens=key_values_lens,
update_past_key_values=True,
is_causal=True,
+ packed_text_ids=packed_text_ids,
**extra_inputs,
)
past_key_values = output.past_key_values
@@ -412,10 +417,10 @@ def forward_cache_update_vit(
packed_indexes: torch.LongTensor,
packed_key_value_indexes: torch.LongTensor,
key_values_lens: torch.IntTensor,
+ language_model_forward: Optional[Callable[..., Any]] = None,
):
- packed_text_embedding = self.language_model.model.embed_tokens(packed_text_ids)
- packed_sequence = packed_text_embedding.new_zeros((sum(packed_seqlens), self.hidden_size))
- packed_sequence[packed_text_indexes] = packed_text_embedding
+ if language_model_forward is None:
+ language_model_forward = self.language_model
cu_seqlens = torch.nn.functional.pad(torch.cumsum(vit_token_seqlens, dim=0), (1, 0))
cu_seqlens = cu_seqlens.to(torch.int32)
@@ -429,6 +434,9 @@ def forward_cache_update_vit(
packed_vit_token_embed = self.connector(packed_vit_token_embed)
pos_emb = self.vit_pos_embed(packed_vit_position_ids)
packed_vit_token_embed = packed_vit_token_embed + pos_emb
+ packed_sequence = packed_vit_token_embed.new_zeros(
+ (sum(packed_seqlens), self.hidden_size), dtype=self.language_model.dtype
+ )
if packed_vit_token_embed.dtype != packed_sequence.dtype:
packed_vit_token_embed = packed_vit_token_embed.to(packed_sequence.dtype)
packed_sequence[packed_vit_token_indexes] = packed_vit_token_embed
@@ -437,7 +445,7 @@ def forward_cache_update_vit(
if self.use_moe:
extra_inputs = {"mode": "und"}
- output = self.language_model.forward_inference(
+ output = language_model_forward(
packed_query_sequence=packed_sequence,
query_lens=packed_seqlens,
packed_query_position_ids=packed_position_ids,
@@ -447,6 +455,8 @@ def forward_cache_update_vit(
key_values_lens=key_values_lens,
update_past_key_values=True,
is_causal=False,
+ packed_text_ids=packed_text_ids,
+ packed_text_indexes=packed_text_indexes,
**extra_inputs,
)
past_key_values = output.past_key_values
@@ -558,10 +568,10 @@ def forward_cache_update_vae(
packed_indexes: torch.LongTensor,
key_values_lens: torch.IntTensor,
packed_key_value_indexes: torch.Tensor,
+ language_model_forward: Optional[Callable[..., Any]] = None,
):
- packed_text_embedding = self.language_model.model.embed_tokens(packed_text_ids)
- packed_sequence = packed_text_embedding.new_zeros((sum(packed_seqlens), self.hidden_size))
- packed_sequence[packed_text_indexes] = packed_text_embedding
+ if language_model_forward is None:
+ language_model_forward = self.language_model
padded_latent = vae_model.encode(padded_images)
@@ -575,6 +585,9 @@ def forward_cache_update_vae(
packed_pos_embed = self.latent_pos_embed(packed_vae_position_ids)
packed_timestep_embeds = self.time_embedder(packed_timesteps)
packed_latent = self.vae2llm(packed_latent) + packed_timestep_embeds + packed_pos_embed
+ packed_sequence = packed_latent.new_zeros(
+ (sum(packed_seqlens), self.hidden_size), dtype=self.language_model.dtype
+ )
if packed_latent.dtype != packed_sequence.dtype:
packed_latent = packed_latent.to(packed_sequence.dtype)
packed_sequence[packed_vae_token_indexes] = packed_latent
@@ -584,10 +597,9 @@ def forward_cache_update_vae(
extra_inputs = {
"mode": "gen",
"packed_vae_token_indexes": packed_vae_token_indexes,
- "packed_text_indexes": packed_text_indexes,
}
- output = self.language_model.forward_inference(
+ output = language_model_forward(
packed_query_sequence=packed_sequence,
query_lens=packed_seqlens,
packed_query_position_ids=packed_position_ids,
@@ -597,6 +609,8 @@ def forward_cache_update_vae(
packed_key_value_indexes=packed_key_value_indexes,
update_past_key_values=True,
is_causal=False,
+ packed_text_ids=packed_text_ids,
+ packed_text_indexes=packed_text_indexes,
**extra_inputs,
)
past_key_values = output.past_key_values
diff --git a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
index d81e96f3c..9d0ad9328 100644
--- a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
+++ b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
@@ -1312,7 +1312,7 @@ def forward_train(
def forward_inference(
self,
- packed_query_sequence: torch.Tensor,
+ packed_query_sequence: Optional[torch.Tensor],
query_lens: torch.Tensor,
packed_query_position_ids: torch.Tensor,
packed_query_indexes: torch.Tensor,
@@ -1324,8 +1324,39 @@ def forward_inference(
mode="und",
packed_vae_token_indexes=None,
packed_text_indexes=None,
+ packed_text_ids: Optional[torch.LongTensor] = None,
) -> BaseNavitOutputWithPast:
+ # Keep token embedding inside the outer language-model forward. Distributed
+ # wrappers attach their unshard hooks to this boundary, so callers must not
+ # reach through to ``model.embed_tokens`` while the parameters are sharded.
+ if packed_text_ids is not None:
+ packed_text_embedding = self.model.embed_tokens(packed_text_ids)
+ if packed_query_sequence is None:
+ packed_query_sequence = packed_text_embedding
+ else:
+ if packed_text_indexes is None:
+ raise ValueError(
+ "packed_text_indexes is required when inserting packed_text_ids "
+ "into an existing packed_query_sequence"
+ )
+ if packed_text_ids.numel() != packed_text_indexes.numel():
+ raise ValueError(
+ "packed_text_ids and packed_text_indexes must contain the same "
+ f"number of tokens, got {packed_text_ids.numel()} and "
+ f"{packed_text_indexes.numel()}"
+ )
+ packed_query_sequence = packed_query_sequence.index_copy(
+ 0,
+ packed_text_indexes,
+ packed_text_embedding.to(packed_query_sequence.dtype),
+ )
+ elif packed_query_sequence is None:
+ raise ValueError(
+ "Qwen2ForCausalLM.forward_inference requires packed_query_sequence "
+ "or packed_text_ids"
+ )
+
outputs = self.model(
packed_query_sequence=packed_query_sequence,
query_lens=query_lens,
diff --git a/tests/models/test_bagel_tdm_contracts.py b/tests/models/test_bagel_tdm_contracts.py
index 9d6bf1010..627f925ab 100644
--- a/tests/models/test_bagel_tdm_contracts.py
+++ b/tests/models/test_bagel_tdm_contracts.py
@@ -18,7 +18,7 @@
import importlib.machinery
import sys
import types
-from types import MethodType
+from types import MethodType, SimpleNamespace
from typing import Any
import pytest
@@ -27,7 +27,7 @@
from peft import LoraConfig, get_peft_model
import flow_factory.utils.imports as import_utils
-from flow_factory.models.model_bundle import ModelBundle
+from flow_factory.models.model_bundle import ModelBundle, RoutedComponentProxy
from flow_factory.samples import BaseSample
from flow_factory.trainers.distillation.distillation_runtime import (
validate_media_free_rollout,
@@ -58,6 +58,13 @@ def _load_bagel_qwen_types(monkeypatch: pytest.MonkeyPatch) -> tuple[type, type]
return module.Qwen2Config, module.Qwen2ForCausalLM
+def _load_bagel_model_type(monkeypatch: pytest.MonkeyPatch) -> type:
+ """Load the vendored Bagel container behind the optional-kernel seam."""
+ _load_bagel_types(monkeypatch)
+ module = importlib.import_module("flow_factory.models.bagel.modeling.bagel.bagel")
+ return module.Bagel
+
+
class _FakeRotaryEmbedding(torch.nn.Module):
def forward(
self,
@@ -115,6 +122,38 @@ def _checkpointing_qwen_model(
return model, decoder
+def _raw_token_qwen_proxy(
+ monkeypatch: pytest.MonkeyPatch,
+) -> tuple[RoutedComponentProxy, torch.nn.Module]:
+ """Build the real PEFT/bundle route with a lightweight decoder body."""
+ qwen_config_type, qwen_causal_lm_type = _load_bagel_qwen_types(monkeypatch)
+ config = qwen_config_type(
+ vocab_size=8,
+ hidden_size=8,
+ intermediate_size=16,
+ num_hidden_layers=1,
+ num_attention_heads=2,
+ num_key_value_heads=2,
+ max_position_embeddings=16,
+ layer_module="Qwen2DecoderLayer",
+ qk_norm=False,
+ _attn_implementation="eager",
+ pad_token_id=0,
+ )
+ base_model = qwen_causal_lm_type(config)
+ model = get_peft_model(
+ base_model,
+ LoraConfig(r=2, lora_alpha=2, target_modules=["q_proj"]),
+ )
+ routed_base = model.get_base_model()
+ routed_base.model.layers = torch.nn.ModuleList([_CheckpointedDecoderLayer()])
+ routed_base.model.rotary_emb = _FakeRotaryEmbedding()
+ routed_base.model.norm = torch.nn.Identity()
+ routed_base.model.use_moe = False
+ bundle = ModelBundle({"transformer": model})
+ return RoutedComponentProxy(bundle, "transformer", model), routed_base
+
+
def _adapter(adapter_type: type, decoder: Any) -> Any:
adapter = object.__new__(adapter_type)
adapter.decode_latents = MethodType(decoder, adapter)
@@ -184,6 +223,175 @@ def test_bagel_qwen_checkpointing_does_not_replay_cache_updates(
assert decoder.calls == 1
+def test_bagel_qwen_routes_raw_text_embedding_through_outer_forward(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ transformer, base_model = _raw_token_qwen_proxy(monkeypatch)
+ with torch.no_grad():
+ base_model.model.embed_tokens.weight.copy_(
+ torch.arange(64, dtype=torch.float32).reshape(8, 8) / 10
+ )
+
+ text_ids = torch.tensor([1, 2])
+ output = transformer(
+ packed_query_sequence=None,
+ query_lens=torch.tensor([2]),
+ packed_query_position_ids=torch.tensor([0, 1]),
+ packed_query_indexes=torch.tensor([4, 5]),
+ past_key_values=object(),
+ key_values_lens=torch.tensor([4]),
+ packed_key_value_indexes=torch.arange(4),
+ update_past_key_values=True,
+ is_causal=True,
+ packed_text_ids=text_ids,
+ # These are merged-cache indexes for text prefill, not query-local
+ # insertion indexes. The sequence=None path must ignore them.
+ packed_text_indexes=torch.tensor([4, 5]),
+ )
+
+ expected = base_model.model.embed_tokens(text_ids).square()
+ torch.testing.assert_close(output.packed_query_sequence, expected)
+
+
+def test_bagel_qwen_inserts_raw_text_without_detaching_aux_sequence(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ transformer, base_model = _raw_token_qwen_proxy(monkeypatch)
+ base_model.model.embed_tokens.weight.requires_grad_(True)
+ with torch.no_grad():
+ base_model.model.embed_tokens.weight.copy_(
+ torch.arange(64, dtype=torch.float32).reshape(8, 8) / 10
+ )
+
+ aux_sequence = torch.full((3, 8), 2.0, requires_grad=True)
+ text_ids = torch.tensor([1])
+ output = transformer(
+ packed_query_sequence=aux_sequence,
+ query_lens=torch.tensor([3]),
+ packed_query_position_ids=torch.tensor([0, 0, 0]),
+ packed_query_indexes=torch.tensor([0, 1, 2]),
+ past_key_values=object(),
+ key_values_lens=torch.tensor([0]),
+ packed_key_value_indexes=torch.tensor([], dtype=torch.long),
+ update_past_key_values=False,
+ is_causal=False,
+ packed_text_ids=text_ids,
+ packed_text_indexes=torch.tensor([1]),
+ )
+ output.packed_query_sequence.sum().backward()
+
+ expected_input = aux_sequence.detach().clone()
+ expected_input[1] = base_model.model.embed_tokens(text_ids).detach()[0]
+ torch.testing.assert_close(output.packed_query_sequence, expected_input.square())
+ torch.testing.assert_close(aux_sequence.grad[0], torch.full((8,), 4.0))
+ torch.testing.assert_close(aux_sequence.grad[1], torch.zeros(8))
+ torch.testing.assert_close(aux_sequence.grad[2], torch.full((8,), 4.0))
+ assert base_model.model.embed_tokens.weight.grad is not None
+ assert base_model.model.embed_tokens.weight.grad[1].abs().sum() > 0
+
+
+def test_bagel_text_cache_uses_injected_language_model_forward(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ bagel_type = _load_bagel_model_type(monkeypatch)
+ bagel = object.__new__(bagel_type)
+ torch.nn.Module.__init__(bagel)
+ bagel.use_moe = False
+
+ class ExplodingPhysicalLanguageModel(torch.nn.Module):
+ def forward(self, *args: Any, **kwargs: Any) -> Any:
+ del args, kwargs
+ raise AssertionError("the physical language model must not be called")
+
+ @property
+ def model(self) -> Any:
+ raise AssertionError("the physical embedding must not be accessed")
+
+ bagel.language_model = ExplodingPhysicalLanguageModel()
+ returned_cache = object()
+ calls: list[dict[str, Any]] = []
+
+ def routed_forward(**kwargs: Any) -> Any:
+ calls.append(kwargs)
+ return SimpleNamespace(past_key_values=returned_cache)
+
+ result = bagel.forward_cache_update_text(
+ past_key_values=object(),
+ packed_text_ids=torch.tensor([1, 2]),
+ packed_text_position_ids=torch.tensor([0, 1]),
+ text_token_lens=torch.tensor([2]),
+ packed_text_indexes=torch.tensor([0, 1]),
+ packed_key_value_indexes=torch.tensor([], dtype=torch.long),
+ key_values_lens=torch.tensor([0]),
+ language_model_forward=routed_forward,
+ )
+
+ assert result is returned_cache
+ assert len(calls) == 1
+ assert calls[0]["packed_query_sequence"] is None
+ assert torch.equal(calls[0]["packed_text_ids"], torch.tensor([1, 2]))
+
+
+def test_bagel_adapter_injects_prepared_route_into_all_cache_updates(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter_type, _ = _load_bagel_types(monkeypatch)
+ routed_transformer = object()
+ forwarded_routes: list[Any] = []
+
+ class FakeBagel:
+ def prepare_prompts(self, **kwargs: Any) -> tuple[dict[str, Any], list[int], list[int]]:
+ del kwargs
+ return {}, [1], [1]
+
+ def forward_cache_update_text(
+ self, past_key_values: Any, *, language_model_forward: Any
+ ) -> Any:
+ del past_key_values
+ forwarded_routes.append(language_model_forward)
+ return "text-cache"
+
+ def prepare_vae_images(self, **kwargs: Any) -> tuple[dict[str, Any], list[int], list[int]]:
+ del kwargs
+ return {}, [2], [2]
+
+ def forward_cache_update_vae(
+ self, vae_model: Any, past_key_values: Any, *, language_model_forward: Any
+ ) -> Any:
+ del vae_model, past_key_values
+ forwarded_routes.append(language_model_forward)
+ return "vae-cache"
+
+ def prepare_vit_images(self, **kwargs: Any) -> tuple[dict[str, Any], list[int], list[int]]:
+ del kwargs
+ return {}, [3], [3]
+
+ def forward_cache_update_vit(
+ self, past_key_values: Any, *, language_model_forward: Any
+ ) -> Any:
+ del past_key_values
+ forwarded_routes.append(language_model_forward)
+ return "vit-cache"
+
+ holder = SimpleNamespace(
+ pipeline=SimpleNamespace(bagel=FakeBagel(), vae=object()),
+ device=torch.device("cpu"),
+ transformer=routed_transformer,
+ _tokenizer=object(),
+ new_token_ids={},
+ vae_transform=object(),
+ vit_transform=object(),
+ )
+ context = {"kv_lens": [0], "ropes": [0], "past_key_values": object()}
+
+ text_context = adapter_type._update_context_text(holder, ["prompt"], context)
+ image_context = adapter_type._update_context_image(holder, [torch.zeros(3, 8, 8)], context)
+
+ assert text_context["past_key_values"] == "text-cache"
+ assert image_context["past_key_values"] == "vit-cache"
+ assert forwarded_routes == [routed_transformer] * 3
+
+
@pytest.mark.parametrize(
"layer_module",
["Qwen2DecoderLayer", "Qwen2MoEDecoderLayer", "Qwen2MoTDecoderLayer"],
From 82018db367afe6867d0bed98538373b630efeee1 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 20:24:03 +0800
Subject: [PATCH 38/76] [trainer] fix: align FSDP2 checkpoint ownership
---
.agents/knowledge/topics/fix_patterns.md | 18 +++++
guidance/new_model.md | 15 +++--
src/flow_factory/trainers/abc.py | 35 +++++++---
tests/models/test_variant_checkpointing.py | 18 ++++-
.../test_distributed_plan_validation.py | 65 +++++++++++++++++--
5 files changed, 128 insertions(+), 23 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 1f2d9efb1..a54a5045e 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -395,6 +395,24 @@ Based on the fix type, write the fix entry to the appropriate document:
masks the first failure and breaks distributed lifecycle semantics.
- **Related Constraint**: #9
+### FSDP2 activation checkpoints must replay inside the mixed-precision boundary
+- **Date**: 2026-08-30
+- **Symptom**: All four Wan FSDP2 trainers failed on their first backward because checkpointed
+ tensors were saved as BF16 but recomputed as FP32.
+- **Root Cause**: Model-level checkpointing captured FP32 block inputs before FSDP2's forward-input
+ cast, while backward replay re-entered a block in `PRE_BACKWARD` state where PyTorch deliberately
+ skips that cast.
+- **Fix**: When full model checkpointing and FSDP2 activation checkpointing are both requested, the
+ trainer now disables model-level boundaries and keeps Accelerate's backend checkpoint wrappers,
+ which replay inside the fully-sharded mixed-precision boundary. Selective policies fail closed
+ because backend checkpointing cannot preserve their exact selection, while FSDP1 retains its
+ existing owner. Wan FSDP2 GRPO, TDM, SFT, and offline DPO plus SD3.5 and Bagel regressions verify
+ the shared path.
+- **Lesson**: Checkpoint placement is part of distributed precision semantics. A recompute boundary
+ outside a sharded module may not replay its forward hooks, so backend-aligned checkpoint wrappers
+ must own FSDP2 full checkpointing instead of nesting model-level boundaries around sharded blocks.
+- **Related Constraint**: #9, #20
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/guidance/new_model.md b/guidance/new_model.md
index 71367702d..8c86bd80c 100644
--- a/guidance/new_model.md
+++ b/guidance/new_model.md
@@ -69,12 +69,15 @@ Diffusers model's `_repeated_blocks` declaration. Adapters with multiple forward
stacks should override `_gradient_checkpointing_units()` and return their blocks
in execution order.
-Checkpointing has one owner. An explicit train-level model policy disables FSDP
-activation checkpointing to avoid nested recomputation; when the train-level
-policy is disabled, the backend may apply full FSDP activation checkpointing.
-Transformers-style components support full checkpointing through
-`gradient_checkpointing_enable()`, but must expose the Diffusers callback API to
-support selective modes.
+Checkpointing has one owner. When FSDP2 full model checkpointing and backend
+activation checkpointing are both enabled, the model policy yields ownership to
+the backend so recomputation stays inside the sharded mixed-precision boundary.
+FSDP1 keeps model-level ownership. FSDP2 rejects a selective train-level policy
+combined with backend activation checkpointing, because the backend cannot
+preserve the requested `fraction`, `every_n`, or `layers` boundary; disable
+backend activation checkpointing when using those policies. Transformers-style
+components support full checkpointing through `gradient_checkpointing_enable()`,
+but must expose the Diffusers callback API to support selective modes.
## Step-by-Step Implementation
diff --git a/src/flow_factory/trainers/abc.py b/src/flow_factory/trainers/abc.py
index d84e9b08d..0ecb77551 100644
--- a/src/flow_factory/trainers/abc.py
+++ b/src/flow_factory/trainers/abc.py
@@ -1175,10 +1175,8 @@ def _apply_backend_checkpointing_constraints(self) -> None:
)
)
fsdp_checkpointing = bool(getattr(fsdp_plugin, "activation_checkpointing", False))
- if (
- self.training_args.trainer_type == "tdm-r1"
- and getattr(fsdp_plugin, "fsdp_version", 1) < 2
- ):
+ fsdp_version = getattr(fsdp_plugin, "fsdp_version", 1) or 1
+ if self.training_args.trainer_type == "tdm-r1" and fsdp_version < 2:
if not model_checkpointing and not fsdp_checkpointing:
return
self.adapter.disable_gradient_checkpointing()
@@ -1194,11 +1192,30 @@ def _apply_backend_checkpointing_constraints(self) -> None:
return
if model_checkpointing and fsdp_checkpointing:
- fsdp_plugin.activation_checkpointing = False
- logger.info(
- "Disabled FSDP activation checkpointing because train-level model "
- "checkpointing is enabled; nested checkpoint boundaries duplicate recompute."
- )
+ if fsdp_version >= 2:
+ checkpoint_policy = self.training_args.enable_gradient_checkpointing
+ full_checkpointing = checkpoint_policy is True or (
+ getattr(checkpoint_policy, "mode", None) == "full"
+ )
+ if not full_checkpointing:
+ raise ValueError(
+ "FSDP2 activation checkpointing cannot preserve selective model "
+ "checkpointing boundaries. Disable fsdp_activation_checkpointing or "
+ "use train.enable_gradient_checkpointing=true/mode=full."
+ )
+ self.adapter.disable_gradient_checkpointing()
+ self.training_args.enable_gradient_checkpointing = False
+ logger.info(
+ "Disabled model gradient checkpointing because FSDP2 activation "
+ "checkpointing is enabled; checkpoint recomputation must stay inside "
+ "the FSDP2 mixed-precision boundary."
+ )
+ else:
+ fsdp_plugin.activation_checkpointing = False
+ logger.info(
+ "Disabled FSDP activation checkpointing because train-level model "
+ "checkpointing is enabled; nested checkpoint boundaries duplicate recompute."
+ )
def _initialization(self):
self._validate_paradigm_dynamics()
diff --git a/tests/models/test_variant_checkpointing.py b/tests/models/test_variant_checkpointing.py
index 3540c334f..797543267 100644
--- a/tests/models/test_variant_checkpointing.py
+++ b/tests/models/test_variant_checkpointing.py
@@ -27,9 +27,7 @@
from flow_factory.models.abc import BaseAdapter
from flow_factory.models.model_bundle import ModelBundle, RoutedComponentProxy
from flow_factory.models.variants import DEFAULT_BASE_VARIANT as BASE_VARIANT
-from flow_factory.models.variants import (
- ComponentVariantRegistry,
-)
+from flow_factory.models.variants import ComponentVariantRegistry
from flow_factory.trainers.abc import BaseTrainer
from flow_factory.trainers.common.runtime_state import TrainerRuntimeState
from flow_factory.trainers.distillation.tdm_r1 import (
@@ -257,6 +255,20 @@ def _trainer_runtime(
return trainer
+def test_disabling_gradient_checkpointing_visits_every_materialized_variant() -> None:
+ trainer = _trainer_runtime("full")
+ members = trainer.adapter.component_variant_registry.bundle_members()
+ disabled_routes = []
+ for route_name, component in members.items():
+ component.disable_gradient_checkpointing = (
+ lambda route_name=route_name: disabled_routes.append(route_name)
+ )
+
+ trainer.adapter.disable_gradient_checkpointing()
+
+ assert disabled_routes == list(members)
+
+
def _step_fake_role(trainer: TinyTrainer) -> None:
coordinator = trainer.role_optimization
fake_parameter = trainer.optimization_roles["fake"].parameters[0]
diff --git a/tests/trainers/test_distributed_plan_validation.py b/tests/trainers/test_distributed_plan_validation.py
index 28cf3ded9..0766c89f3 100644
--- a/tests/trainers/test_distributed_plan_validation.py
+++ b/tests/trainers/test_distributed_plan_validation.py
@@ -233,9 +233,7 @@ def test_tdm_r1_fsdp1_disables_incompatible_activation_checkpointing() -> None:
trainer_type="tdm-r1",
enable_gradient_checkpointing=True,
),
- adapter=SimpleNamespace(
- disable_gradient_checkpointing=lambda: disabled.append(True)
- ),
+ adapter=SimpleNamespace(disable_gradient_checkpointing=lambda: disabled.append(True)),
)
BaseTrainer._apply_backend_checkpointing_constraints(trainer)
@@ -245,10 +243,17 @@ def test_tdm_r1_fsdp1_disables_incompatible_activation_checkpointing() -> None:
assert plugin.activation_checkpointing is False
-def test_fsdp2_keeps_model_checkpointing_and_disables_nested_backend_checkpointing() -> None:
+@pytest.mark.parametrize(
+ "checkpoint_policy",
+ [True, SimpleNamespace(mode="full")],
+)
+def test_fsdp2_disables_model_checkpointing_and_keeps_backend_checkpointing(
+ checkpoint_policy: object,
+) -> None:
from flow_factory.trainers.abc import BaseTrainer
plugin = SimpleNamespace(fsdp_version=2, activation_checkpointing=True)
+ disabled = []
trainer = SimpleNamespace(
accelerator=SimpleNamespace(
distributed_type=DistributedType.FSDP,
@@ -256,11 +261,34 @@ def test_fsdp2_keeps_model_checkpointing_and_disables_nested_backend_checkpointi
),
training_args=SimpleNamespace(
trainer_type="tdm-r1",
+ enable_gradient_checkpointing=checkpoint_policy,
+ ),
+ adapter=SimpleNamespace(disable_gradient_checkpointing=lambda: disabled.append(True)),
+ )
+
+ BaseTrainer._apply_backend_checkpointing_constraints(trainer)
+
+ assert disabled == [True]
+ assert trainer.training_args.enable_gradient_checkpointing is False
+ assert plugin.activation_checkpointing is True
+
+
+def test_fsdp1_keeps_model_checkpointing_and_disables_nested_backend_checkpointing() -> None:
+ from flow_factory.trainers.abc import BaseTrainer
+
+ plugin = SimpleNamespace(fsdp_version=1, activation_checkpointing=True)
+ trainer = SimpleNamespace(
+ accelerator=SimpleNamespace(
+ distributed_type=DistributedType.FSDP,
+ state=SimpleNamespace(fsdp_plugin=plugin),
+ ),
+ training_args=SimpleNamespace(
+ trainer_type="grpo",
enable_gradient_checkpointing=True,
),
adapter=SimpleNamespace(
disable_gradient_checkpointing=lambda: pytest.fail(
- "FSDP2 must keep model checkpointing enabled"
+ "FSDP1 must keep model checkpointing enabled"
)
),
)
@@ -271,6 +299,33 @@ def test_fsdp2_keeps_model_checkpointing_and_disables_nested_backend_checkpointi
assert plugin.activation_checkpointing is False
+def test_fsdp2_rejects_selective_model_and_backend_checkpointing() -> None:
+ from flow_factory.trainers.abc import BaseTrainer
+
+ plugin = SimpleNamespace(fsdp_version=2, activation_checkpointing=True)
+ trainer = SimpleNamespace(
+ accelerator=SimpleNamespace(
+ distributed_type=DistributedType.FSDP,
+ state=SimpleNamespace(fsdp_plugin=plugin),
+ ),
+ training_args=SimpleNamespace(
+ trainer_type="grpo",
+ enable_gradient_checkpointing=SimpleNamespace(mode="every_n"),
+ ),
+ adapter=SimpleNamespace(
+ disable_gradient_checkpointing=lambda: pytest.fail(
+ "selective checkpointing must fail before mutating the adapter"
+ )
+ ),
+ )
+
+ with pytest.raises(ValueError, match="cannot preserve selective"):
+ BaseTrainer._apply_backend_checkpointing_constraints(trainer)
+
+ assert trainer.training_args.enable_gradient_checkpointing.mode == "every_n"
+ assert plugin.activation_checkpointing is True
+
+
def test_fsdp2_keeps_backend_checkpointing_when_model_policy_is_disabled() -> None:
from flow_factory.trainers.abc import BaseTrainer
From 7b3295d388790f9d801127d9052ae53e0110149e Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 20:34:05 +0800
Subject: [PATCH 39/76] [models] fix: allow optional dtype manifest selectors
---
.agents/knowledge/topics/fix_patterns.md | 17 ++++++++
src/flow_factory/models/abc.py | 12 ++++++
src/flow_factory/models/precision.py | 12 +++++-
tests/models/test_model_precision_policy.py | 45 ++++++++++++++++++++-
4 files changed, 82 insertions(+), 4 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index a54a5045e..dd414c97a 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -413,6 +413,23 @@ Based on the fix type, write the fix entry to the appropriate document:
must own FSDP2 full checkpointing instead of nesting model-level boundaries around sharded blocks.
- **Related Constraint**: #9, #20
+### Adapter dtype manifests may span optional checkpoint components
+- **Date**: 2026-08-30
+- **Symptom**: Every Wan2.2 TI2V trainer rejected the Wan I2V adapter's `image_encoder` load-dtype
+ default even though the checkpoint legitimately omits that optional component.
+- **Root Cause**: The eager loader validated an adapter-wide dtype manifest only against components
+ present in one checkpoint, conflating the checkpoint instance with the pipeline class's wider
+ optional-component contract.
+- **Fix**: Eager pipeline loading now validates adapter manifest selectors against the union of the
+ checkpoint components and the pipeline class's declared optional components, while resolving
+ dtype arguments only for components actually present. User overrides remain strict against the
+ selected checkpoint. Regressions cover absent and present optional components, invalid manifest
+ selectors, and explicit user selection of an absent component.
+- **Lesson**: Adapter defaults may intentionally cover several checkpoint variants of one pipeline
+ class. Optional class-level declarations belong to manifest validation, but they must not create
+ components or weaken the fail-fast contract for checkpoint-specific user overrides.
+- **Related Constraint**: #20
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index d110e78a1..e2abfef2d 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -963,12 +963,24 @@ def _load_diffusers_pipeline(
for name, value in pipeline_config.items()
if isinstance(value, (list, tuple)) and len(value) >= 2
]
+ # Adapter defaults may cover several checkpoint variants of one pipeline
+ # class. Keep absent class-declared optional components valid for manifest
+ # validation, but resolve the actual dtype mapping only for this checkpoint.
+ manifest_declared_names = list(
+ dict.fromkeys(
+ [
+ *component_names,
+ *getattr(pipeline_class, "_optional_components", ()),
+ ]
+ )
+ )
load_dtype_kwargs = build_component_load_dtype_kwargs(
user_policy=user_policy,
manifest_policy=manifest_policy,
component_names=component_names,
transformer_names=[name for name in component_names if "transformer" in name],
text_encoder_names=[name for name in component_names if "text_encoder" in name],
+ manifest_declared_names=manifest_declared_names,
preserve_unselected=True,
)
kwargs.update(
diff --git a/src/flow_factory/models/precision.py b/src/flow_factory/models/precision.py
index e75a133be..4537b7fe8 100644
--- a/src/flow_factory/models/precision.py
+++ b/src/flow_factory/models/precision.py
@@ -123,10 +123,16 @@ def component_dtype_mapping(
component_names: Sequence[str],
transformer_names: Sequence[str],
text_encoder_names: Sequence[str],
+ manifest_declared_names: Sequence[str] | None = None,
) -> dict[str, torch.dtype]:
- """Resolve a policy to the concrete non-null mapping accepted by loaders."""
+ """Resolve policies for concrete components, with wider adapter-manifest declarations."""
validate_dtype_policy_selectors(user_policy, declared_names=component_names)
- validate_dtype_policy_selectors(manifest_policy, declared_names=component_names)
+ validate_dtype_policy_selectors(
+ manifest_policy,
+ declared_names=(
+ component_names if manifest_declared_names is None else manifest_declared_names
+ ),
+ )
return {
name: dtype
for name in component_names
@@ -150,6 +156,7 @@ def build_component_load_dtype_kwargs(
component_names: Sequence[str],
transformer_names: Sequence[str],
text_encoder_names: Sequence[str],
+ manifest_declared_names: Sequence[str] | None = None,
requested_names: Sequence[str] | None = None,
preserve_unselected: bool = False,
) -> Dict[str, object]:
@@ -165,6 +172,7 @@ def build_component_load_dtype_kwargs(
component_names=component_names,
transformer_names=transformer_names,
text_encoder_names=text_encoder_names,
+ manifest_declared_names=manifest_declared_names,
)
if requested_names is not None:
requested = set(requested_names)
diff --git a/tests/models/test_model_precision_policy.py b/tests/models/test_model_precision_policy.py
index 2047d375e..91036c697 100644
--- a/tests/models/test_model_precision_policy.py
+++ b/tests/models/test_model_precision_policy.py
@@ -14,8 +14,8 @@
import pytest
import torch
-from diffusers import DiffusionPipeline
+from diffusers import DiffusionPipeline
from flow_factory.models.abc import BaseAdapter
from flow_factory.models.precision import (
cast_module_role_dtypes,
@@ -44,6 +44,7 @@ def __init__(self) -> None:
class _PipelineFake(DiffusionPipeline):
load_call = None
+ _optional_components = ["image_encoder"]
@classmethod
def load_config(cls, pretrained_model_name_or_path: str, **kwargs):
@@ -86,6 +87,17 @@ def test_user_default_null_disables_concrete_manifest_defaults() -> None:
)
+def test_present_optional_manifest_selector_resolves_its_dtype() -> None:
+ assert component_dtype_mapping(
+ user_policy=None,
+ manifest_policy={"image_encoder": torch.float32},
+ component_names=["transformer", "image_encoder"],
+ transformer_names=["transformer"],
+ text_encoder_names=[],
+ manifest_declared_names=["transformer", "image_encoder"],
+ ) == {"image_encoder": torch.float32}
+
+
def test_transformers_group_includes_reference_transformer() -> None:
assert component_dtype_mapping(
user_policy={"transformers": torch.bfloat16},
@@ -101,7 +113,10 @@ def test_eager_pipeline_loader_expands_role_selectors() -> None:
"AdapterStub",
(),
{
- "_component_load_dtype_manifest": {"transformers": torch.bfloat16},
+ "_component_load_dtype_manifest": {
+ "transformers": torch.bfloat16,
+ "image_encoder": torch.float32,
+ },
"_component_load_dtype_overrides": None,
"_resolve_component_load_dtype_mapping": (
BaseAdapter._resolve_component_load_dtype_mapping
@@ -130,6 +145,20 @@ def test_eager_pipeline_loader_expands_role_selectors() -> None:
)
+def test_user_policy_cannot_select_an_absent_optional_component() -> None:
+ adapter = type(
+ "AdapterStub",
+ (),
+ {
+ "_component_load_dtype_manifest": None,
+ "_component_load_dtype_overrides": {"image_encoder": torch.float32},
+ },
+ )()
+
+ with pytest.raises(ValueError, match=r"unknown=.*image_encoder"):
+ BaseAdapter._load_diffusers_pipeline(adapter, _PipelineFake, "model")
+
+
def test_unknown_load_policy_selector_fails_with_runtime_context() -> None:
with pytest.raises(ValueError, match=r"unknown=.*missing.*declared=.*transformer"):
component_dtype_mapping(
@@ -141,6 +170,18 @@ def test_unknown_load_policy_selector_fails_with_runtime_context() -> None:
)
+def test_unknown_manifest_selector_remains_strict_with_optional_declarations() -> None:
+ with pytest.raises(ValueError, match=r"unknown=.*missing"):
+ component_dtype_mapping(
+ user_policy=None,
+ manifest_policy={"missing": torch.bfloat16},
+ component_names=["transformer"],
+ transformer_names=["transformer"],
+ text_encoder_names=[],
+ manifest_declared_names=["transformer", "image_encoder"],
+ )
+
+
def test_role_cast_preserves_diffusers_fp32_islands_but_not_lora_dtype() -> None:
model = _ProtectedDiffusersModel()
model.requires_grad_(False)
From 589ae026023fb127897c90ac75ffa8bf2a6d82ad Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 20:40:36 +0800
Subject: [PATCH 40/76] [models,loading] fix: separate absent component roots
---
.agents/knowledge/topics/fix_patterns.md | 16 ++++++
src/flow_factory/loading/coordinator.py | 13 ++++-
src/flow_factory/models/runtime/classic.py | 2 +
tests/loading/test_coordinator.py | 61 +++++++++++++++++++++-
4 files changed, 89 insertions(+), 3 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index dd414c97a..2758caa57 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -430,6 +430,22 @@ Based on the fix type, write the fix entry to the appropriate document:
components or weaken the fail-fast contract for checkpoint-specific user overrides.
- **Related Constraint**: #20
+### Absent optional components need distinct physical roots
+- **Date**: 2026-08-30
+- **Symptom**: Every Wan2.2 TI2V trainer rejected its load plan because the physical
+ `image_encoder` root appeared to combine incompatible auxiliary and host roles.
+- **Root Cause**: The eager runtime used object identity to collapse logical aliases, so multiple
+ optional components whose value was the singleton `None` were mistaken for one shared physical
+ object.
+- **Fix**: Classic pipelines now preserve a declared optional `None` under its own logical root
+ while retaining identity aliasing for real objects. The load coordinator finalizes only
+ replicated roots that actually materialized, preventing FSDP replica checks from resolving an
+ allowed absent component.
+- **Lesson**: Object identity establishes physical aliasing only for materialized objects. Optional
+ declarations retain distinct lifecycle identities, and backend finalization must follow observed
+ materialization rather than the requested name set.
+- **Related Constraint**: #9
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/loading/coordinator.py b/src/flow_factory/loading/coordinator.py
index 770f8327e..bfec60399 100644
--- a/src/flow_factory/loading/coordinator.py
+++ b/src/flow_factory/loading/coordinator.py
@@ -113,9 +113,18 @@ def load_components(
)
if not replicated and not remainder_roots:
return
+ loaded_replicated: List[str] = []
with self.load_scope(ComponentRole.AUXILIARY):
if replicated:
self.adapter.on_load_components(components=replicated, device=device)
+ materialized_roots = set(
+ self.adapter.component_runtime.materialized_component_names
+ )
+ loaded_replicated = [
+ name
+ for name in replicated
+ if self.plan.descriptors[name].root in materialized_roots
+ ]
for root in remainder_roots:
request = self.plan.request_for_root(root)
excluded_paths = [
@@ -128,8 +137,8 @@ def load_components(
excluded_paths=excluded_paths,
device=device,
)
- if replicated:
- self.components_loaded(replicated)
+ if loaded_replicated:
+ self.components_loaded(loaded_replicated)
def prepare(self, *objects: Any) -> Any:
return self.backend.prepare(*objects)
diff --git a/src/flow_factory/models/runtime/classic.py b/src/flow_factory/models/runtime/classic.py
index 5ea7fca7e..897d35a8c 100644
--- a/src/flow_factory/models/runtime/classic.py
+++ b/src/flow_factory/models/runtime/classic.py
@@ -56,6 +56,8 @@ def physical_route(self, name: str) -> tuple[str, tuple[str, ...]]:
"""Collapse logical aliases that reference one canonical module object."""
self._validate_declared_names([name])
component = self.declared_components[name]
+ if component is None:
+ return name, ()
for canonical_name, canonical_component in self.canonical_components.items():
if component is canonical_component:
return canonical_name, ()
diff --git a/tests/loading/test_coordinator.py b/tests/loading/test_coordinator.py
index db2977c36..1de700ed8 100644
--- a/tests/loading/test_coordinator.py
+++ b/tests/loading/test_coordinator.py
@@ -45,6 +45,27 @@ def test_classic_runtime_collapses_same_object_aliases() -> None:
assert runtime.physical_route("text_encoder") == ("text_encoder_2", ())
+def test_classic_runtime_keeps_absent_optional_components_as_distinct_roots() -> None:
+ pipeline = SimpleNamespace(
+ components={"image_encoder": None, "image_processor": None},
+ image_encoder=None,
+ image_processor=None,
+ )
+ runtime = ClassicPipelineRuntime(pipeline)
+ adapter = SimpleNamespace(
+ component_runtime=runtime,
+ model_args=SimpleNamespace(target_components=[]),
+ _resolve_component_names=runtime.resolve_component_names,
+ )
+
+ plan = build_adapter_load_plan(adapter)
+
+ assert runtime.physical_route("image_encoder") == ("image_encoder", ())
+ assert runtime.physical_route("image_processor") == ("image_processor", ())
+ assert plan.request_for_component("image_encoder").role is ComponentRole.AUXILIARY
+ assert plan.request_for_component("image_processor").role is ComponentRole.HOST
+
+
def test_coordinator_expands_target_component_groups() -> None:
transformer = torch.nn.Linear(2, 2)
runtime = PseudoPipelineRuntime(
@@ -122,9 +143,10 @@ def test_coordinator_moves_only_auxiliary_remainder_of_target_owned_root() -> No
_resolve_component_names=lambda components: list(components),
on_load_components=lambda components, device: calls.append(("load", components, device)),
component_runtime=SimpleNamespace(
+ materialized_component_names=["bagel", "vae"],
load_root_remainder=lambda root, excluded_paths, device: calls.append(
("remainder", root, excluded_paths, device)
- )
+ ),
),
)
coordinator.load_scope = lambda role: nullcontext()
@@ -147,6 +169,43 @@ def test_coordinator_moves_only_auxiliary_remainder_of_target_owned_root() -> No
]
+def test_coordinator_finalizes_only_materialized_replicas() -> None:
+ plan = LoadPlanner().build(
+ [
+ ComponentDescriptor(
+ name="image_encoder",
+ root="image_encoder",
+ role=ComponentRole.AUXILIARY,
+ ),
+ ComponentDescriptor(
+ name="vae",
+ root="vae",
+ role=ComponentRole.AUXILIARY,
+ ),
+ ]
+ )
+ calls = []
+ coordinator = object.__new__(ModelLoadCoordinator)
+ coordinator.plan = plan
+ coordinator.adapter = SimpleNamespace(
+ _resolve_component_names=lambda components: list(components),
+ on_load_components=lambda components, device: calls.append(("load", components, device)),
+ component_runtime=SimpleNamespace(materialized_component_names=["vae"]),
+ )
+ coordinator.load_scope = lambda role: nullcontext()
+ coordinator.components_loaded = lambda components: calls.append(("finalize", components))
+
+ coordinator.load_components(
+ ["image_encoder", "vae"],
+ device=torch.device("cpu"),
+ )
+
+ assert calls == [
+ ("load", ["image_encoder", "vae"], torch.device("cpu")),
+ ("finalize", ["vae"]),
+ ]
+
+
def test_pseudo_runtime_does_not_move_excluded_target_submodule() -> None:
class TrackingModule(torch.nn.Module):
def __init__(self):
From e6fcb360557f9bb1400b31572fc79780442bbb44 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 20:45:14 +0800
Subject: [PATCH 41/76] [dependencies] fix: install Wan prompt normalizer
---
.agents/knowledge/topics/fix_patterns.md | 14 ++++++++++++++
pyproject.toml | 3 ++-
tests/dependencies/test_diffusers_pin.py | 12 ++++++++++++
3 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 2758caa57..9b9b2c982 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -446,6 +446,20 @@ Based on the fix type, write the fix entry to the appropriate document:
materialization rather than the requested name set.
- **Related Constraint**: #9
+### Supported adapter paths require their upstream optional dependencies
+- **Date**: 2026-08-30
+- **Symptom**: Every Wan I2V trainer reached prompt preprocessing and then failed with
+ `NameError: name 'ftfy' is not defined` inside Diffusers prompt normalization.
+- **Root Cause**: Diffusers imports `ftfy` conditionally and declares it only in development/test
+ extras, while its Wan I2V prompt helper calls the package unconditionally. Flow-Factory exposes
+ Wan I2V as a core adapter but did not close that runtime dependency gap.
+- **Fix**: `ftfy` is now a core project dependency, with a metadata regression that keeps exactly
+ one install requirement for Wan prompt normalization.
+- **Lesson**: A framework that promotes an upstream optional code path to a supported core feature
+ also owns the path's transitive runtime dependencies; successful import of the upstream module
+ does not prove its conditionally imported helpers are callable.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/pyproject.toml b/pyproject.toml
index 6bdd6a172..7957a5024 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,7 @@ dependencies = [
# Critical for T5/Flux
"protobuf>=6.33.2",
"sentencepiece>=0.2.1",
+ "ftfy", # Required by Diffusers Wan prompt normalization
# Basic Utils
"av>=18.0.0",
@@ -118,4 +119,4 @@ target-version = ['py310', 'py311', 'py312']
[tool.isort]
profile = "black"
-line_length = 100
\ No newline at end of file
+line_length = 100
diff --git a/tests/dependencies/test_diffusers_pin.py b/tests/dependencies/test_diffusers_pin.py
index 4faa9e982..c48156c78 100644
--- a/tests/dependencies/test_diffusers_pin.py
+++ b/tests/dependencies/test_diffusers_pin.py
@@ -17,6 +17,7 @@
ROOT = Path(__file__).resolve().parents[2]
EXPECTED_DIFFUSERS_REQUIREMENT = "diffusers>=0.40.0"
+EXPECTED_WAN_PROMPT_DEPENDENCY = "ftfy"
def test_project_metadata_requires_released_diffusers_with_h3_support() -> None:
@@ -28,3 +29,14 @@ def test_project_metadata_requires_released_diffusers_with_h3_support() -> None:
f"MiniMax H3 support; expected={EXPECTED_DIFFUSERS_REQUIREMENT!r}, "
f"observed={diffusers_requirements!r}"
)
+
+
+def test_project_metadata_requires_wan_prompt_normalization_dependency() -> None:
+ pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ ftfy_requirements = re.findall(r'"(ftfy[^"]*)"', pyproject)
+
+ assert ftfy_requirements == [EXPECTED_WAN_PROMPT_DEPENDENCY], (
+ "pyproject.toml must contain exactly one core ftfy requirement for Wan prompt "
+ f"normalization; expected={EXPECTED_WAN_PROMPT_DEPENDENCY!r}, "
+ f"observed={ftfy_requirements!r}"
+ )
From a1e01fc0893ec603be60ea9be81de710365f881b Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 20:51:48 +0800
Subject: [PATCH 42/76] [models] fix: validate Wan condition temporal geometry
---
.agents/knowledge/topics/fix_patterns.md | 15 +++++++++++++++
src/flow_factory/models/wan/_conditioning.py | 3 ++-
tests/models/test_wan_output_codec.py | 14 ++++++++++++--
3 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 9b9b2c982..e35b4824c 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -460,6 +460,21 @@ Based on the fix type, write the fix entry to the appropriate document:
does not prove its conditionally imported helpers are callable.
- **Related Constraint**: N/A
+### Condition-latent geometry follows the encoded source, not the rollout target
+- **Date**: 2026-08-30
+- **Symptom**: Wan2.2 TI2V rejected a one-frame VAE condition latent with temporal size one because
+ the rollout noise and target video had temporal latent size two.
+- **Root Cause**: The condition validator derived its expected temporal size from configured output
+ frames. In the expanded-timestep pipeline, Diffusers intentionally encodes only the first input
+ frame and broadcasts that one-frame condition through a full-length first-frame mask.
+- **Fix**: Wan condition validation now derives temporal geometry from the actual video tensor sent
+ to the VAE. The regression models one-frame encoding and proves the official broadcast produces
+ the full rollout shape while non-expanded conditions keep their full temporal encoding.
+- **Lesson**: Conditioning and generated states can share channels and spatial geometry without
+ sharing sequence length. Validate each representation against its own source transform before
+ relying on an explicitly defined broadcast or mask contract.
+- **Related Constraint**: #7
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/wan/_conditioning.py b/src/flow_factory/models/wan/_conditioning.py
index 71556ab1d..47503ab55 100644
--- a/src/flow_factory/models/wan/_conditioning.py
+++ b/src/flow_factory/models/wan/_conditioning.py
@@ -319,10 +319,11 @@ def prepare_wan_i2v_condition_tensors(
).to(device=device, dtype=dtype)
latent_condition = normalize_wan_video_latents(adapter, latent_condition)
+ condition_latent_frames = (video_condition.shape[2] - 1) // temporal_scale + 1
expected_latents = (
batch_size,
vae.config.z_dim,
- num_latent_frames,
+ condition_latent_frames,
latent_height,
latent_width,
)
diff --git a/tests/models/test_wan_output_codec.py b/tests/models/test_wan_output_codec.py
index e3dab0ae4..ab0a935f4 100644
--- a/tests/models/test_wan_output_codec.py
+++ b/tests/models/test_wan_output_codec.py
@@ -72,11 +72,15 @@ def __init__(self) -> None:
latents_std=[2.0, 4.0, 5.0],
)
raw_channels = torch.tensor([3.0, 6.0, 8.0]).view(1, 3, 1, 1, 1)
- self.posterior = _Posterior(raw_channels.expand(1, 3, 2, 2, 2).clone())
+ self.full_latents = raw_channels.expand(1, 3, 2, 2, 2).clone()
+ self.posterior = _Posterior(self.full_latents)
self.encoded_pixels: list[torch.Tensor] = []
def encode(self, pixels: torch.Tensor) -> Any:
self.encoded_pixels.append(pixels)
+ self.posterior.latents = (
+ self.full_latents[:, :, :1] if pixels.shape[2] == 1 else self.full_latents
+ )
return SimpleNamespace(latent_dist=self.posterior)
@@ -384,8 +388,14 @@ def test_wan_i2v_expand_condition_binds_target_active_mask() -> None:
condition = prepared.forward_context["latent_condition"]
mask = prepared.forward_context["first_frame_mask"]
- assert condition.shape == (1, 3, 2, 2, 2)
+ assert condition.shape == (1, 3, 1, 2, 2)
assert mask.shape == (1, 1, 2, 2, 2)
+ assert adapter.vae.encoded_pixels[0].shape[2] == 1
+ rollout_latents = torch.full((1, 3, 2, 2, 2), 2.0)
+ blended = (1 - mask) * condition + mask * rollout_latents
+ assert blended.shape == rollout_latents.shape
+ torch.testing.assert_close(blended[:, :, 0], condition[:, :, 0])
+ torch.testing.assert_close(blended[:, :, 1], rollout_latents[:, :, 1])
torch.testing.assert_close(mask[:, :, 0], torch.zeros(1, 1, 2, 2))
torch.testing.assert_close(mask[:, :, 1], torch.ones(1, 1, 2, 2))
assert prepared.output_context["first_frame_mask"] is mask
From 140a7a3914cc45c92aeb1e6c7b6925d3f8ed1bd9 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 20:56:22 +0800
Subject: [PATCH 43/76] [models] fix: normalize Wan sample condition rows
---
.agents/knowledge/topics/fix_patterns.md | 14 ++++++++++++++
src/flow_factory/models/wan/wan2_i2v.py | 2 +-
tests/models/test_wan_output_codec.py | 17 +++++++++++++++--
3 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index e35b4824c..4de65f872 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -475,6 +475,20 @@ Based on the fix type, write the fix entry to the appropriate document:
relying on an explicitly defined broadcast or mask contract.
- **Related Constraint**: #7
+### Internal immutable media rows must cross sample boundaries as public batch types
+- **Date**: 2026-08-30
+- **Symptom**: Wan I2V rollouts finished denoising but failed while constructing each sample because
+ image canonicalization rejected a tuple of ordered first/last frames.
+- **Root Cause**: Wan's internal condition normalizer intentionally returns immutable tuple rows,
+ while `ImageConditionSample.condition_images` follows the public `ImageBatch` contract of lists,
+ tensors, or arrays. The adapter passed the internal representation across that boundary unchanged.
+- **Fix**: Wan now converts each ordered condition row to a list at sample construction. The
+ regression proves first/last color order survives sample canonicalization and replay stacking.
+- **Lesson**: Model-internal containers may enforce stronger invariants than shared sample APIs, but
+ adapters must translate them explicitly at the ownership boundary instead of broadening a common
+ media utility for one private representation.
+- **Related Constraint**: #5
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/wan/wan2_i2v.py b/src/flow_factory/models/wan/wan2_i2v.py
index 297d97c59..f7049c21b 100644
--- a/src/flow_factory/models/wan/wan2_i2v.py
+++ b/src/flow_factory/models/wan/wan2_i2v.py
@@ -769,7 +769,7 @@ def inference(
height=height,
width=width,
# Conditions
- condition_images=condition_rows[b],
+ condition_images=list(condition_rows[b]),
latent_condition=condition[b],
first_frame_mask=(first_frame_mask[b] if first_frame_mask is not None else None),
image_embeds=per_sample_image_embeds[b],
diff --git a/tests/models/test_wan_output_codec.py b/tests/models/test_wan_output_codec.py
index ab0a935f4..4de48de78 100644
--- a/tests/models/test_wan_output_codec.py
+++ b/tests/models/test_wan_output_codec.py
@@ -442,12 +442,25 @@ def test_wan_i2v_expand_target_requires_prepared_active_mask_before_vae() -> Non
def test_wan_i2v_normalization_never_truncates_optional_last_frame() -> None:
- first = Image.new("RGB", (4, 4))
- last = Image.new("RGB", (4, 4))
+ first = Image.new("RGB", (4, 4), color="red")
+ last = Image.new("RGB", (4, 4), color="blue")
rows = normalize_wan_i2v_image_rows([[first, last]], expected_batch_size=1)
+ sample = WanI2VSample(condition_images=list(rows[0]))
+ stacked = WanI2VSample.stack([sample])
assert rows == ((first, last),)
+ assert len(sample.condition_images) == 2
+ torch.testing.assert_close(sample.condition_images[0][:, 0, 0], torch.tensor([1.0, 0.0, 0.0]))
+ torch.testing.assert_close(sample.condition_images[1][:, 0, 0], torch.tensor([0.0, 0.0, 1.0]))
+ assert len(stacked["condition_images"]) == 1
+ assert len(stacked["condition_images"][0]) == 2
+ torch.testing.assert_close(
+ stacked["condition_images"][0][0][:, 0, 0], torch.tensor([1.0, 0.0, 0.0])
+ )
+ torch.testing.assert_close(
+ stacked["condition_images"][0][1][:, 0, 0], torch.tensor([0.0, 0.0, 1.0])
+ )
def test_wan_i2v_online_prepare_latents_reuses_condition_mode_path() -> None:
From 96228defb105ebacb0a7d58261584386e5d72f06 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 21:12:35 +0800
Subject: [PATCH 44/76] [dataset,docs] fix: use Wan FLF2V checkpoint
---
.agents/knowledge/topics/fix_patterns.md | 14 ++++++++++++++
README.md | 1 +
dataset/offline_smoke/profiles.py | 2 +-
guidance/gpu_validation.md | 15 ++++++++-------
tests/dataset/test_offline_smoke_profiles.py | 1 +
tests/docs/test_minimax_h3_docs.py | 3 +++
6 files changed, 28 insertions(+), 8 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 4de65f872..8e9473426 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -489,6 +489,20 @@ Based on the fix type, write the fix entry to the appropriate document:
media utility for one private representation.
- **Related Constraint**: #5
+### Endpoint-conditioned checkpoints are not interchangeable with I2V checkpoints
+- **Date**: 2026-08-30
+- **Symptom**: Every Wan first/last-frame smoke reached the transformer but failed while concatenating
+ image and text states because their leading dimensions were two and one.
+- **Root Cause**: The FLF2V matrix profile selected a standard Wan2.1 I2V checkpoint, whose image
+ projection lacks the learned endpoint positional embedding that folds two ordered CLIP image rows
+ back into one logical sample.
+- **Fix**: The public smoke profile and GPU validation plan now select the dedicated Wan2.1 FLF2V
+ checkpoint, and the supported-model table documents that checkpoint explicitly.
+- **Lesson**: Checkpoints that share a Diffusers pipeline class can still implement distinct
+ conditioning contracts. Validation profiles must bind semantic modes to weights whose trained
+ embedding layout realizes that mode instead of relying only on adapter-class compatibility.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/README.md b/README.md
index 6e6ca2788..c62bb489f 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,7 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
| Wan2.1-I2V-14B-720P | 14B | wan2_i2v |
| Wan2.2-TI2V-5B | 5B | wan2_i2v |
| Wan2.2-I2V-A14B | A14B | wan2_i2v |
+ | First/Last-Frame-to-Video | Wan2.1-FLF2V-14B-720P | 14B | wan2_i2v |
| Text-to-Audio-Video | LTX-2 | 19B | ltx2_t2av |
| LTX-2.3 | 22B | ltx2_t2av |
diff --git a/dataset/offline_smoke/profiles.py b/dataset/offline_smoke/profiles.py
index d273719ee..bc2073e60 100644
--- a/dataset/offline_smoke/profiles.py
+++ b/dataset/offline_smoke/profiles.py
@@ -209,7 +209,7 @@ def _image_rule(
"bagel-mri2i": "ByteDance-Seed/BAGEL-7B-MoT",
"wan-t2v": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"wan-i2v-first": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
- "wan-flf2v": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
+ "wan-flf2v": "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers",
"ltx2-t2av": "Lightricks/LTX-2",
"ltx2-i2av": "Lightricks/LTX-2",
"h3-t2va": "MiniMaxAI/MiniMax-H3",
diff --git a/guidance/gpu_validation.md b/guidance/gpu_validation.md
index 92935b7a3..272b19f1c 100644
--- a/guidance/gpu_validation.md
+++ b/guidance/gpu_validation.md
@@ -37,7 +37,7 @@ different condition layouts and active masks.
| `bagel-mri2i` | Bagel ordered multi-reference-images-to-image regression anchor | `ByteDance-Seed/BAGEL-7B-MoT` | prompt plus exactly two ordered images per sample | image |
| `wan-t2v` | Wan text-to-video | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | prompt | video |
| `wan-i2v-first` | Wan first-frame-to-video | `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | exactly one first-frame image | video |
-| `wan-flf2v` | Wan first/last-frame-to-video | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | ordered first and last images | video |
+| `wan-flf2v` | Wan first/last-frame-to-video | `Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers` | ordered first and last images | video |
| `ltx2-t2av` | LTX2 text-to-audio-video | `Lightricks/LTX-2` | prompt | ordered video and audio |
| `ltx2-i2av` | LTX2 image-to-audio-video | `Lightricks/LTX-2` | prompt plus one image | ordered video and audio |
| `h3-t2va` | MiniMax H3 text-to-video-audio | `MiniMaxAI/MiniMax-H3` | prompt | ordered video and audio |
@@ -94,7 +94,7 @@ recorded in the resolved YAML; do not silently return to a large quality recipe.
| `bagel-mri2i` | `examples/grpo/lora/bagel/i2i.yaml` | `resolution: 256` | `num_inference_steps: 2` | Every row has exactly two ordered references; keep `shuffle_samples: false`. |
| `wan-t2v` | `examples/grpo/lora/wan21/t2v.yaml` | `resolution: 240`, `num_frames: 5` | `num_inference_steps: 2` | Target video has at least five frames and carries its source `fps`. |
| `wan-i2v-first` | `examples/grpo/lora/wan22/i2v.yaml` | `resolution: 240`, `num_frames: 5` | `num_inference_steps: 2` | Use TI2V-5B and exactly one condition image. |
-| `wan-flf2v` | `examples/grpo/lora/wan21/i2v.yaml` | `resolution: 240`, `num_frames: 5` | `num_inference_steps: 2` | Use two ordered condition images; do not use an expanded-timestep checkpoint. |
+| `wan-flf2v` | `examples/grpo/lora/wan21/i2v.yaml` | `resolution: 240`, `num_frames: 5` | `num_inference_steps: 2` | Use the dedicated FLF2V checkpoint with two ordered condition images. |
| `ltx2-t2av` | `examples/grpo/lora/ltx2/t2av.yaml` | `resolution: [128, 192]`, `num_frames: 9`, `frame_rate: 24.0` | `num_inference_steps: 2` | AV targets cover the exact 9-frame clock; audio carries `sample_rate`. |
| `ltx2-i2av` | `examples/grpo/lora/ltx2/i2av.yaml` | `resolution: [128, 192]`, `num_frames: 9`, `frame_rate: 24.0` | `num_inference_steps: 2` | One condition image; verify the first latent frame is inactive in the loss. |
| `h3-t2va` | `examples/grpo/lora/minimax_h3_t2va/debug.yaml` | `resolution: [64, 96]`, `num_frames: 124`, `frame_rate: 24.0` | `num_inference_steps: 2` | Preserve the released five-second minimum and neutral guidance. |
@@ -163,14 +163,15 @@ backends and all four algorithms.
|---|---|
| Wan T2V | `Wan2.1-T2V-14B-Diffusers`, `Wan2.2-TI2V-5B-Diffusers`, `Wan2.2-T2V-A14B-Diffusers` |
| Wan I2V first-only | `Wan2.1-I2V-14B-480P-Diffusers`, `Wan2.1-I2V-14B-720P-Diffusers`, `Wan2.2-I2V-A14B-Diffusers` |
-| Wan first/last | `Wan2.1-I2V-14B-720P-Diffusers`, `Wan2.2-I2V-A14B-Diffusers` |
+| Wan first/last | `Wan2.2-I2V-A14B-Diffusers` |
| LTX2 T2AV and I2AV | `dg845/LTX-2.3-Diffusers` |
| MiniMax H3 | The same checkpoint is covered separately by T2VA, FL2VA, and Ref2VA inputs. Add an FL2VA first-plus-last fixture to complement the first-only/last-only main jobs. |
-Wan2.2 TI2V-5B uses expanded timesteps. Official Diffusers ignores a supplied
-last image in that mode, so its effective input contract is first-frame only;
-do not count it as a first/last checkpoint. Other Wan I2V checkpoints must prove
-both first-only and first/last execution.
+Wan2.2 TI2V-5B uses expanded timesteps, and standard CLIP-conditioned Wan2.1 I2V
+checkpoints lack endpoint positional embeddings; both are first-frame only. The
+dedicated Wan2.1 FLF2V checkpoint requires both endpoints. Wan2.2 I2V-A14B does
+not use CLIP image embeddings, so its VAE-only optional-last path remains a
+separate first/last variant gate.
For the Wan2.2 A14B dual-transformer gate, force or instrument two offline
timestep samples so that one routes below the transformer boundary and one
diff --git a/tests/dataset/test_offline_smoke_profiles.py b/tests/dataset/test_offline_smoke_profiles.py
index d615cd1b0..b9efb8b3f 100644
--- a/tests/dataset/test_offline_smoke_profiles.py
+++ b/tests/dataset/test_offline_smoke_profiles.py
@@ -106,6 +106,7 @@ def test_gpu_variants_keep_model_specific_clocks() -> None:
assert (wan.height, wan.num_frames) == (240, 5)
assert (ltx.height, ltx.width, ltx.num_frames, ltx.sample_rate) == (128, 192, 9, 16000)
assert (h3.height, h3.width, h3.num_frames, h3.sample_rate) == (64, 96, 124, 32000)
+ assert cases["wan-flf2v"].checkpoint == "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers"
assert p.get_profile("image-i2i") is p.CANONICAL_PROFILES["image_to_image"]
diff --git a/tests/docs/test_minimax_h3_docs.py b/tests/docs/test_minimax_h3_docs.py
index bbfc174a4..cb4f5e0f2 100644
--- a/tests/docs/test_minimax_h3_docs.py
+++ b/tests/docs/test_minimax_h3_docs.py
@@ -106,6 +106,9 @@ def test_gpu_validation_plan_declares_the_complete_smoke_matrix() -> None:
assert "two training epochs" in text
assert "eval.eval_freq: 0" in text
assert "DistributedSampler" in text
+ assert "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers" in text
+ assert "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | ordered first and last" not in text
+ assert "| Wan first/last | `Wan2.2-I2V-A14B-Diffusers` |" in text
def test_install_docs_use_the_released_diffusers_runtime() -> None:
From e31ed67ec5d5042b0ee4972dde44e21efbe4b092 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 21:16:43 +0800
Subject: [PATCH 45/76] [models,dataset] fix: specialize Wan endpoint contracts
---
.agents/knowledge/topics/fix_patterns.md | 14 +++++++++
dataset/offline_smoke/profiles.py | 4 +--
guidance/gpu_validation.md | 11 ++++---
src/flow_factory/models/wan/wan2_i2v.py | 33 ++++++++++++++++++--
tests/dataset/test_offline_smoke_profiles.py | 3 +-
tests/models/test_wan_output_codec.py | 23 ++++++++++++--
6 files changed, 76 insertions(+), 12 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 8e9473426..78f40ed25 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -503,6 +503,20 @@ Based on the fix type, write the fix entry to the appropriate document:
embedding layout realizes that mode instead of relying only on adapter-class compatibility.
- **Related Constraint**: N/A
+### Wan endpoint cardinality follows the checkpoint embedding path
+- **Date**: 2026-08-30
+- **Symptom**: The Wan I2V adapter advertised one optional last frame for every checkpoint even
+ though standard Wan2.1 I2V and dedicated FLF2V weights require different exact image counts.
+- **Root Cause**: The effective pipeline contract specialized only expanded-timestep checkpoints
+ and ignored whether the loaded transformer used no CLIP image states, ordinary CLIP states, or
+ learned first/last endpoint positional embeddings.
+- **Fix**: Wan now resolves exact-one for expanded or ordinary CLIP-conditioned checkpoints,
+ exact-two for endpoint-positioned FLF2V weights, and preserves one-or-two for Wan2.2's VAE-only
+ condition path. The public FLF smoke profile independently requires both endpoint slots.
+- **Lesson**: A shared adapter's public superset contract must be narrowed from realized checkpoint
+ structure before dataset validation, cache identity, or exact-resume identity is derived.
+- **Related Constraint**: #5
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/dataset/offline_smoke/profiles.py b/dataset/offline_smoke/profiles.py
index bc2073e60..cea883b1e 100644
--- a/dataset/offline_smoke/profiles.py
+++ b/dataset/offline_smoke/profiles.py
@@ -172,10 +172,10 @@ def _image_rule(
)
_FL2V = video_output_contract(
negative_prompt=_NO_NEGATIVE,
- input_image_min_count=1,
+ input_image_min_count=2,
input_image_max_count=2,
input_image_slots=("first_frame", "last_frame"),
- required_input_image_slots=("first_frame",),
+ required_input_image_slots=("first_frame", "last_frame"),
output_fps=RateRequirement.REQUIRED,
)
_T2AV = audio_video_output_contract(negative_prompt=_NO_NEGATIVE)
diff --git a/guidance/gpu_validation.md b/guidance/gpu_validation.md
index 272b19f1c..23b8a6d37 100644
--- a/guidance/gpu_validation.md
+++ b/guidance/gpu_validation.md
@@ -47,11 +47,12 @@ different condition layouts and active masks.
The public V2 schema uses the `type` discriminator and an optional input-only
`slot`. An explicit slot reserves its adapter-declared semantic argument;
unslotted media is only a positional shorthand that fills remaining slots in
-declaration order. Wan requires `first_frame` and optionally accepts
-`last_frame`; H3 FL accepts either slot or both. Supervision media does not
-repeat independent condition-image objects. Its video is nevertheless the full
-configured output sequence, whose first and/or last endpoint must correspond to
-the supplied endpoint conditions.
+declaration order. Standard Wan2.1 I2V checkpoints require `first_frame`, the
+dedicated FLF2V checkpoint requires both endpoints, and Wan2.2 I2V-A14B retains
+an optional VAE-only `last_frame`; H3 FL accepts either slot or both. Supervision
+media does not repeat independent condition-image objects. Its video is
+nevertheless the full configured output sequence, whose first and/or last
+endpoint must correspond to the supplied endpoint conditions.
### Backends
diff --git a/src/flow_factory/models/wan/wan2_i2v.py b/src/flow_factory/models/wan/wan2_i2v.py
index f7049c21b..a08ddf66c 100644
--- a/src/flow_factory/models/wan/wan2_i2v.py
+++ b/src/flow_factory/models/wan/wan2_i2v.py
@@ -122,9 +122,38 @@ def load_pipeline(self) -> WanImageToVideoPipeline:
)
def _resolve_pipeline_io_contract(self) -> PipelineIOContract:
- """Narrow expand-timestep checkpoints to their first-frame-only semantics."""
+ """Resolve checkpoint-specific first/last-frame cardinality."""
+ supports_endpoint_pair = False
if not self.pipeline.config.expand_timesteps:
- return type(self).pipeline_io_contract
+ transformer_configs = tuple(
+ transformer.config
+ for transformer in (
+ getattr(self.pipeline, "transformer", None),
+ getattr(self.pipeline, "transformer_2", None),
+ )
+ if transformer is not None
+ )
+ clip_configs = tuple(
+ config
+ for config in transformer_configs
+ if getattr(config, "image_dim", None) is not None
+ )
+ if not clip_configs:
+ return type(self).pipeline_io_contract
+ supports_endpoint_pair = any(
+ getattr(config, "pos_embed_seq_len", None) is not None for config in clip_configs
+ )
+ if supports_endpoint_pair:
+ return video_output_contract(
+ negative_prompt=NegativePromptPolicy.OPTIONAL,
+ input_image_min_count=2,
+ input_image_max_count=2,
+ input_image_slots=("first_frame", "last_frame"),
+ required_input_image_slots=("first_frame", "last_frame"),
+ output_fps=RateRequirement.REQUIRED,
+ geometry_source=GeometrySource.CONFIGURED,
+ batch_capability=BatchCapability.SINGLE_SAMPLE,
+ )
return video_output_contract(
negative_prompt=NegativePromptPolicy.OPTIONAL,
input_image_min_count=1,
diff --git a/tests/dataset/test_offline_smoke_profiles.py b/tests/dataset/test_offline_smoke_profiles.py
index b9efb8b3f..f7a3ae0cd 100644
--- a/tests/dataset/test_offline_smoke_profiles.py
+++ b/tests/dataset/test_offline_smoke_profiles.py
@@ -79,8 +79,9 @@ def test_image_and_endpoint_profiles_keep_cardinality_order_and_slots() -> None:
assert (first_video.slots, first_video.required_slots) == (("first_frame",), ("first_frame",))
assert (fl_video.slots, fl_video.required_slots) == (
("first_frame", "last_frame"),
- ("first_frame",),
+ ("first_frame", "last_frame"),
)
+ assert (fl_video.min_count, fl_video.max_count) == (2, 2)
assert (first_av.slots, first_av.required_slots) == (("first_frame",), ("first_frame",))
assert (fl_av.slots, fl_av.required_slots) == (("first_frame", "last_frame"), ())
diff --git a/tests/models/test_wan_output_codec.py b/tests/models/test_wan_output_codec.py
index 4de48de78..16e94a891 100644
--- a/tests/models/test_wan_output_codec.py
+++ b/tests/models/test_wan_output_codec.py
@@ -299,7 +299,7 @@ def test_wan_geometry_validator_rejects_output_context_drift() -> None:
)
-def test_wan_i2v_declares_ordered_first_optional_last_offline_contract() -> None:
+def test_wan_i2v_resolves_checkpoint_specific_endpoint_contract() -> None:
contract = Wan2_I2V_Adapter.pipeline_io_contract
assert contract.geometry_source is GeometrySource.CONFIGURED
@@ -322,8 +322,27 @@ def test_wan_i2v_declares_ordered_first_optional_last_offline_contract() -> None
runtime = object.__new__(Wan2_I2V_Adapter)
runtime.pipeline = _Adapter().pipeline
+ runtime.pipeline.transformer.config.image_dim = None
+ runtime.pipeline.transformer.config.pos_embed_seq_len = None
+ runtime.pipeline.transformer_2 = SimpleNamespace(
+ config=SimpleNamespace(image_dim=None, pos_embed_seq_len=None)
+ )
+ effective = Wan2_I2V_Adapter._resolve_pipeline_io_contract(runtime)
+ rule = effective.input_media.rules[0]
+ assert (rule.min_count, rule.max_count) == (1, 2)
+
+ runtime.pipeline.transformer_2 = None
+ runtime.pipeline.transformer.config.image_dim = 1280
effective = Wan2_I2V_Adapter._resolve_pipeline_io_contract(runtime)
- assert effective.input_media.rules[0].max_count == 2
+ rule = effective.input_media.rules[0]
+ assert (rule.min_count, rule.max_count) == (1, 1)
+
+ runtime.pipeline.transformer.config.pos_embed_seq_len = 514
+ effective = Wan2_I2V_Adapter._resolve_pipeline_io_contract(runtime)
+ rule = effective.input_media.rules[0]
+ assert (rule.min_count, rule.max_count) == (2, 2)
+ assert rule.required_slots == ("first_frame", "last_frame")
+
runtime.pipeline.config.expand_timesteps = True
effective = Wan2_I2V_Adapter._resolve_pipeline_io_contract(runtime)
assert effective.input_media.rules[0].max_count == 1
From 54f92efb7f9f34c62158e3909a8be196c6694839 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 21:50:02 +0800
Subject: [PATCH 46/76] [models] fix: expose repeated blocks to FSDP
---
.agents/knowledge/topics/component_runtime.md | 18 ++++++++++++++
src/flow_factory/models/model_bundle.py | 19 ++++++++-------
tests/models/test_component_variants.py | 24 +++++++++++++++++++
3 files changed, 53 insertions(+), 8 deletions(-)
diff --git a/.agents/knowledge/topics/component_runtime.md b/.agents/knowledge/topics/component_runtime.md
index 7bf833aff..06729b22d 100644
--- a/.agents/knowledge/topics/component_runtime.md
+++ b/.agents/knowledge/topics/component_runtime.md
@@ -70,6 +70,24 @@ instance attribute.
- A target-owned composite root may still contain frozen auxiliary siblings. Pseudo runtimes move
only that remainder and exclude every prepared target route.
+## Fix records
+
+### Repeated-block metadata must survive the distributed bundle boundary
+
+- **Date**: 2026-08-30
+- **Symptom**: Two-rank LTX2 FSDP2 runs exhausted a 95 GiB GPU while
+ `fully_shard()` initialized the prepared model root, before the first training step.
+- **Root Cause**: LTX2 declares its 48 transformer units through Diffusers'
+ `_repeated_blocks`, but `ModelBundle` surfaced only `_no_split_modules` to Accelerate;
+ the empty auto-wrap policy therefore sharded the complete 19B transformer as one unit.
+- **Fix**: `ModelBundle._no_split_modules` now falls back to `_repeated_blocks` for a
+ member that has no legacy no-split declaration, with a regression that resolves the
+ repeated block class through Accelerate's transformer-based FSDP policy.
+- **Lesson**: A distributed wrapper becomes the metadata boundary seen by backend
+ policy discovery. It must preserve both legacy and current model block declarations,
+ or a correct component graph can silently collapse into one memory-prohibitive shard.
+- **Related Constraint**: #9
+
## Failure modes
- Expanding omitted materialization to all declarations loads tokenizers, configs, and weights
diff --git a/src/flow_factory/models/model_bundle.py b/src/flow_factory/models/model_bundle.py
index 09dc43339..debf0089b 100644
--- a/src/flow_factory/models/model_bundle.py
+++ b/src/flow_factory/models/model_bundle.py
@@ -69,8 +69,7 @@ def __init__(self, members: Dict[str, nn.Module]):
@property
def _no_split_modules(self):
- """Aggregate the members' ``_no_split_modules`` so accelerate's FSDP
- ``TRANSFORMER_BASED_WRAP`` can discover the transformer block class(es).
+ """Aggregate member block metadata for Accelerate's FSDP wrap policy.
accelerate's ``set_auto_wrap_policy`` reads ``getattr(root, "_no_split_modules")``
off the single root passed to ``prepare`` (this bundle). Without this the
@@ -78,17 +77,21 @@ def _no_split_modules(self):
bundle is wrapped as ONE FSDP unit -> one monolithic flat param (the full
unsharded model materialized on every rank) -> OOM at init for large models
(e.g. Wan2.2 A14B: ~53GB flat param). We surface the block-class *names* that
- the underlying model classes already declare (e.g. diffusers
- ``WanTransformer3DModel._no_split_modules == ['WanTransformerBlock']``);
- accelerate then resolves each name to its class via ``get_module_class_from_name``
- over this root's submodule tree and wraps per-block. Returns ``None`` when no
- member declares any (accelerate keeps its prior fallback unchanged).
+ underlying model classes already declare. Older models use
+ ``_no_split_modules`` (for example ``WanTransformerBlock``), while newer
+ Diffusers models may expose only ``_repeated_blocks`` (for example
+ ``LTX2VideoTransformerBlock``). Accelerate then resolves each name to its
+ class over this root's submodule tree and wraps per block. Returns ``None``
+ when no member declares either form of metadata.
"""
names: list[str] = []
# Walk members' submodule tree (NOT self, to avoid recursing on this property);
- # collect every ``_no_split_modules`` a nested module declares.
+ # preserve the legacy no-split declaration when present and otherwise use
+ # Diffusers' repeated-block declaration.
for module in self.members.modules():
nsm = getattr(module, "_no_split_modules", None)
+ if not nsm:
+ nsm = getattr(module, "_repeated_blocks", None)
if nsm:
names.extend(nsm)
deduped = list(dict.fromkeys(names))
diff --git a/tests/models/test_component_variants.py b/tests/models/test_component_variants.py
index 3d64b2c9e..dda02ffbb 100644
--- a/tests/models/test_component_variants.py
+++ b/tests/models/test_component_variants.py
@@ -20,6 +20,7 @@
import pytest
import torch
+from accelerate import FullyShardedDataParallelPlugin
from peft import LoraConfig, get_peft_model
from flow_factory.models.abc import BaseAdapter
@@ -32,6 +33,29 @@
)
+def test_model_bundle_exposes_diffusers_repeated_blocks_to_fsdp() -> None:
+ class RepeatedBlock(torch.nn.Module):
+ pass
+
+ class RepeatedBlockModel(torch.nn.Module):
+ _repeated_blocks = ["RepeatedBlock"]
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.blocks = torch.nn.ModuleList([RepeatedBlock(), RepeatedBlock()])
+
+ bundle = ModelBundle({"transformer": RepeatedBlockModel()})
+ plugin = FullyShardedDataParallelPlugin(
+ fsdp_version=2,
+ auto_wrap_policy="transformer_based_wrap",
+ )
+
+ plugin.set_auto_wrap_policy(bundle)
+
+ assert bundle._no_split_modules == ["RepeatedBlock"]
+ assert plugin.auto_wrap_policy.keywords["transformer_layer_cls"] == {RepeatedBlock}
+
+
def _registry() -> ComponentVariantRegistry:
return ComponentVariantRegistry(
SimpleNamespace(trainable_component_names=["transformer", "transformer_2"])
From 7684fb6c952c5d07683c13025cc67eb4af6e1bb9 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 22:15:37 +0800
Subject: [PATCH 47/76] [models] fix: restore H3 layout coordinate precision
---
.agents/knowledge/topics/dtype_precision.md | 8 ++++++++
src/flow_factory/models/minimax_h3/workflow.py | 8 +++++++-
tests/models/minimax_h3/test_review_fixes.py | 10 +++++++++-
3 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/.agents/knowledge/topics/dtype_precision.md b/.agents/knowledge/topics/dtype_precision.md
index 2913b9995..0280fdbd9 100644
--- a/.agents/knowledge/topics/dtype_precision.md
+++ b/.agents/knowledge/topics/dtype_precision.md
@@ -86,6 +86,14 @@ The round-trip ensures that the precision of stored latents matches what trainin
- **Lesson**: Compute precision and boundary representation are separate contracts. Restore representation at the producer boundary instead of weakening adapter validation or casting from global configuration.
- **Related Constraint**: N/A
+### H3 cached layout coordinates lost their semantic dtype
+- **Date**: 2026-08-30
+- **Symptom**: MiniMax H3 offline SFT and DPO rejected cached `position_ids` as float32 even though the preprocessing cache schema declared float64 coordinates.
+- **Root Cause**: Hugging Face Dataset's torch formatter defaults floating Arrow columns to float32, and H3 layout normalization preserved that runtime downcast.
+- **Fix**: H3 layout normalization now restores matrix coordinates to float64 before output encoding, with a regression covering a float32 cached tensor.
+- **Lesson**: An Arrow schema dtype does not guarantee the dtype returned by a runtime formatter. Restore model-semantic precision at the adapter normalization boundary before strict validation.
+- **Related Constraint**: #20
+
## Cross-refs
- `constraints.md` #18 (all-rank synchronization — precision errors may manifest differently per rank)
diff --git a/src/flow_factory/models/minimax_h3/workflow.py b/src/flow_factory/models/minimax_h3/workflow.py
index c0702da5d..e7d4665b4 100644
--- a/src/flow_factory/models/minimax_h3/workflow.py
+++ b/src/flow_factory/models/minimax_h3/workflow.py
@@ -959,7 +959,13 @@ def _normalize_layout(values: Mapping[str, Any]) -> Dict[str, Any]:
f"MiniMax H3 layout field={field!r} expected shape (N,D) or "
f"collated (B=1,N,D), received {tuple(value.shape)}"
)
- normalized[field] = value
+ if value.dtype not in (torch.float32, torch.float64):
+ raise ValueError(
+ f"MiniMax H3 layout field={field!r} expected dtype float32 or float64, "
+ f"received {value.dtype}"
+ )
+ # HF Dataset's torch formatter downcasts cached float64 coordinates.
+ normalized[field] = value.to(dtype=torch.float64)
for field in _LAYOUT_INDEX_FIELDS:
if field not in source:
continue
diff --git a/tests/models/minimax_h3/test_review_fixes.py b/tests/models/minimax_h3/test_review_fixes.py
index 91ffd9d76..41ce8d9ec 100644
--- a/tests/models/minimax_h3/test_review_fixes.py
+++ b/tests/models/minimax_h3/test_review_fixes.py
@@ -299,10 +299,11 @@ def test_pinned_preprocessing_runs_under_no_grad(monkeypatch) -> None:
def test_layout_normalization_uses_field_specific_shapes() -> None:
+ position_ids = torch.arange(21, dtype=torch.float32).reshape(1, 7, 3) / 3
layout = workflow._normalize_layout(
{
"layout": {
- "position_ids": torch.zeros(1, 7, 3),
+ "position_ids": position_ids,
"token_tags": torch.arange(7).unsqueeze(0),
"video_indices": torch.arange(2).unsqueeze(0),
"num_condition_video_rows": [1],
@@ -311,11 +312,18 @@ def test_layout_normalization_uses_field_specific_shapes() -> None:
)
assert layout["position_ids"].shape == (7, 3)
+ assert layout["position_ids"].dtype == torch.float64
+ assert torch.equal(layout["position_ids"], position_ids[0].to(torch.float64))
assert layout["token_tags"].shape == (7,)
assert layout["video_indices"].shape == (2,)
assert layout["num_condition_video_rows"] == 1
+def test_layout_normalization_rejects_non_floating_position_ids() -> None:
+ with pytest.raises(ValueError, match="expected dtype float32 or float64, received torch.int64"):
+ workflow._normalize_layout({"position_ids": torch.zeros(7, 3, dtype=torch.int64)})
+
+
def test_decode_materializes_exact_frozen_components(monkeypatch) -> None:
monkeypatch.setattr(
workflow,
From fcac4c5a5b4d0d8b084184fbd56eb503869c92e6 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 22:33:27 +0800
Subject: [PATCH 48/76] [dataset] fix: forward ordered reference manifests
---
.agents/knowledge/topics/fix_patterns.md | 15 +++++++++++++++
src/flow_factory/data_utils/dataset.py | 5 ++++-
tests/data_utils/test_ordered_references.py | 4 ++++
3 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 78f40ed25..b67fcadb0 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -517,6 +517,21 @@ Based on the fix type, write the fix entry to the appropriate document:
structure before dataset validation, cache identity, or exact-resume identity is derived.
- **Related Constraint**: #5
+### Ordered-reference preprocessors need the canonical manifest sidecar
+- **Date**: 2026-08-30
+- **Symptom**: MiniMax H3 Ref2VA failed during distributed dataset preprocessing because its
+ strict workflow received decoded `references` but `reference_manifest=None`.
+- **Root Cause**: `GeneralDataset` canonicalized and retained each ordered-reference manifest for
+ Arrow output, but omitted that same manifest from the arguments passed to the adapter
+ preprocessor.
+- **Fix**: Ordered-reference preprocessing now forwards the canonical manifest beside the decoded
+ transient media, and the real-media round-trip regression requires the preprocessor to receive
+ the same canonical ordering that is stored in the cache.
+- **Lesson**: Identity and reconstruction sidecars must cross the same preprocessing boundary as
+ the transient inputs they describe. Preserve strict batch validation at the adapter instead of
+ delaying a missing-sidecar failure until sample construction.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index f7af6eccd..cf7e09653 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -720,7 +720,10 @@ def _preprocess_batch(
for reference_index, entry in enumerate(json.loads(manifest))
]
)
- reference_args["references"] = loaded_reference_batch
+ reference_args.update(
+ references=loaded_reference_batch,
+ reference_manifest=canonical_manifests,
+ )
batch["reference_manifest"] = canonical_manifests
slot_args = {
diff --git a/tests/data_utils/test_ordered_references.py b/tests/data_utils/test_ordered_references.py
index 18a02ddf0..6cc984eac 100644
--- a/tests/data_utils/test_ordered_references.py
+++ b/tests/data_utils/test_ordered_references.py
@@ -38,11 +38,13 @@ class OrderedPreprocessor:
def __init__(self) -> None:
self.received: List[List[Dict[str, Any]]] = []
+ self.received_manifests: List[str] = []
def preprocess(
self,
prompt: List[str],
references: List[List[Dict[str, Any]]],
+ reference_manifest: List[str],
workflow: str,
width: int,
height: int = 512,
@@ -51,6 +53,7 @@ def preprocess(
model_name_or_path: str = "MiniMaxAI/MiniMax-H3",
) -> Dict[str, Any]:
self.received = references
+ self.received_manifests = reference_manifest
assert workflow == "ref2va"
return {"encoded": torch.tensor([[len(references[0])]], dtype=torch.float32)}
@@ -143,6 +146,7 @@ def test_ordered_references_round_trip_real_media_and_merged_cache(tmp_path: Pat
assert preprocessor.received[0][0]["media"].size == (4, 3)
assert preprocessor.received[0][1]["sample_rate"] == 22050
assert preprocessor.received[0][1]["media"].shape[0] == 1
+ assert json.loads(preprocessor.received_manifests[0]) == references
row = dataset[0]
assert json.loads(row["reference_manifest"]) == references
assert all(value is not None for value in row.values())
From 2b68c2f6a8a2374797b3911f7b52bdca4c641223 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 23:07:29 +0800
Subject: [PATCH 49/76] [models] fix: bound H3 feed-forward token memory
---
.agents/knowledge/topics/fix_patterns.md | 15 ++
.../models/minimax_h3/_chunking.py | 153 ++++++++++++++++++
.../models/minimax_h3/workflow.py | 6 +-
tests/models/minimax_h3/test_chunking.py | 151 +++++++++++++++++
.../minimax_h3/test_workflow_adapters.py | 42 ++++-
5 files changed, 362 insertions(+), 5 deletions(-)
create mode 100644 src/flow_factory/models/minimax_h3/_chunking.py
create mode 100644 tests/models/minimax_h3/test_chunking.py
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index b67fcadb0..1028f3853 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -532,6 +532,21 @@ Based on the fix type, write the fix entry to the appropriate document:
delaying a missing-sidecar failure until sample construction.
- **Related Constraint**: N/A
+### Token-local H3 feed-forward work should bound packed-sequence temporaries
+- **Date**: 2026-08-30
+- **Symptom**: MiniMax H3 Ref2VA ZeRO-2 offline DPO and FSDP2 trainers exhausted a 95 GiB
+ device while allocating one 380 MiB SwiGLU activation for a 13,889-token packed sequence.
+- **Root Cause**: Diffusers' H3 blocks evaluated every feed-forward projection over the complete
+ dynamic sequence, and its generic chunk helper rejected non-divisible lengths such as 13,889.
+- **Fix**: H3 runtime setup now reuses each existing `ff.net` parameter tree inside a remainder-safe
+ 4,096-token executor for both token-refiner and main transformer blocks. Installation precedes
+ Flow-Factory resume loading, LoRA, checkpointing, and distributed wrapping, so state-dict keys,
+ parameter identities, and execution policy remain consistent across rollout and training.
+- **Lesson**: A token-local operation does not need to inherit the peak allocation of a packed
+ attention sequence. Apply memory bounds at the operation boundary while preserving the original
+ parameter tree and accepting dynamic tail chunks.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
new file mode 100644
index 000000000..63dd64f7c
--- /dev/null
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -0,0 +1,153 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bound peak memory for token-local MiniMax H3 feed-forward layers."""
+
+from __future__ import annotations
+
+from typing import Iterable
+
+import torch
+from torch import nn
+
+H3_MAX_FEED_FORWARD_TOKENS = 4096
+
+
+class _ChunkedFeedForward(nn.Module):
+ """Run one existing feed-forward network over remainder-safe token chunks.
+
+ The upstream Diffusers H3 blocks expose their feed-forward layers as a sole
+ ``net`` ModuleList. Registering that same ModuleList directly keeps parameter
+ identities and ``ff.net.*`` state-dict/LoRA paths unchanged.
+ """
+
+ def __init__(self, net: nn.ModuleList, *, max_tokens: int) -> None:
+ super().__init__()
+ self.net = net
+ self.max_tokens = _positive_int(max_tokens, "max_tokens")
+
+ def _forward_chunk(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ for module in self.net:
+ hidden_states = module(hidden_states)
+ return hidden_states
+
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ """Apply the token-local network without requiring an even split."""
+ if hidden_states.ndim < 3:
+ raise ValueError(
+ "MiniMax H3 feed-forward chunking expected [batch, tokens, hidden] "
+ f"input, received shape={tuple(hidden_states.shape)}"
+ )
+ if hidden_states.shape[1] <= self.max_tokens:
+ return self._forward_chunk(hidden_states)
+ return torch.cat(
+ [self._forward_chunk(chunk) for chunk in hidden_states.split(self.max_tokens, dim=1)],
+ dim=1,
+ )
+
+
+def install_h3_feed_forward_chunking(
+ transformer: nn.Module,
+ *,
+ max_tokens: int = H3_MAX_FEED_FORWARD_TOKENS,
+) -> int:
+ """Install bounded feed-forward execution on both H3 repeated stacks.
+
+ Installation happens immediately after pretrained component materialization,
+ before Flow-Factory resume loading, LoRA injection, gradient checkpointing, or
+ distributed wrapping. The mutation is idempotent and preserves every parameter
+ object.
+
+ Returns:
+ Number of feed-forward layers configured across both stacks.
+ """
+ max_tokens = _positive_int(max_tokens, "max_tokens")
+ blocks = tuple(_h3_feed_forward_blocks(transformer))
+ configured = 0
+ for name, block in blocks:
+ feed_forward = getattr(block, "ff", None)
+ if isinstance(feed_forward, _ChunkedFeedForward):
+ if feed_forward.max_tokens != max_tokens:
+ raise ValueError(
+ f"MiniMax H3 {name}.ff already uses max_tokens="
+ f"{feed_forward.max_tokens}, received conflicting {max_tokens}"
+ )
+ configured += 1
+ continue
+ net = getattr(feed_forward, "net", None)
+ if not isinstance(net, nn.ModuleList):
+ raise TypeError(
+ f"MiniMax H3 {name}.ff expected a sole net ModuleList, received "
+ f"{type(feed_forward).__name__} with net={type(net).__name__}"
+ )
+ if tuple(feed_forward._modules) != ("net",):
+ raise TypeError(
+ f"MiniMax H3 {name}.ff expected only the net child module, received "
+ f"children={tuple(feed_forward._modules)}"
+ )
+ if not (
+ len(net) == 3
+ and type(net[0]).__name__ == "SwiGLU"
+ and isinstance(net[1], nn.Dropout)
+ and net[1].p == 0.0
+ and isinstance(net[2], nn.Linear)
+ ):
+ raise TypeError(
+ f"MiniMax H3 {name}.ff expected SwiGLU, Dropout(0), Linear; "
+ f"received={[type(module).__name__ for module in net]}"
+ )
+ if tuple(feed_forward.named_parameters(recurse=False)) or tuple(
+ feed_forward.named_buffers(recurse=False)
+ ):
+ raise TypeError(f"MiniMax H3 {name}.ff expected no direct parameters or buffers")
+ hook_fields = (
+ "_forward_pre_hooks",
+ "_forward_hooks",
+ "_backward_pre_hooks",
+ "_backward_hooks",
+ )
+ active_hooks = tuple(field for field in hook_fields if getattr(feed_forward, field, None))
+ if active_hooks or getattr(feed_forward, "_hf_hook", None) is not None:
+ raise TypeError(
+ f"MiniMax H3 {name}.ff must be configured before execution hooks; "
+ f"received hooks={active_hooks}, hf_hook="
+ f"{type(getattr(feed_forward, '_hf_hook', None)).__name__}"
+ )
+ replacement = _ChunkedFeedForward(net, max_tokens=max_tokens)
+ replacement.train(feed_forward.training)
+ block.ff = replacement
+ configured += 1
+ return configured
+
+
+def _h3_feed_forward_blocks(transformer: nn.Module) -> Iterable[tuple[str, nn.Module]]:
+ token_refiner = getattr(transformer, "token_refiner", None)
+ stacks = (
+ ("token_refiner.refiner_blocks", getattr(token_refiner, "refiner_blocks", None)),
+ ("transformer_blocks", getattr(transformer, "transformer_blocks", None)),
+ )
+ for stack_name, stack in stacks:
+ if not isinstance(stack, nn.ModuleList) or not stack:
+ raise TypeError(
+ f"MiniMax H3 expected non-empty {stack_name} ModuleList, received "
+ f"{type(stack).__name__}"
+ )
+ for index, block in enumerate(stack):
+ yield f"{stack_name}.{index}", block
+
+
+def _positive_int(value: int, field: str) -> int:
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
+ raise ValueError(f"MiniMax H3 {field} expected a positive int, received {value!r}")
+ return value
diff --git a/src/flow_factory/models/minimax_h3/workflow.py b/src/flow_factory/models/minimax_h3/workflow.py
index e7d4665b4..0b3376247 100644
--- a/src/flow_factory/models/minimax_h3/workflow.py
+++ b/src/flow_factory/models/minimax_h3/workflow.py
@@ -30,13 +30,12 @@
)
from ...scheduler import MiniMaxH3SDEScheduler, SchedulerGroup
from ..runtime import ModularPipelineRuntime
+from ._chunking import install_h3_feed_forward_chunking
from ._common import (
build_structured_trajectories,
)
from ._common import build_training_component_times as build_h3_component_times
-from ._common import (
- validate_target_state,
-)
+from ._common import validate_target_state
from .blocks import encode_h3_workflow_inputs, prepare_h3_rollout_state
from .decoding import decode_h3_targets
from .denoise import forward_h3_state
@@ -98,6 +97,7 @@ def build_h3_component_runtime(adapter: Any) -> ModularPipelineRuntime:
validate_h3_target_components(adapter)
runtime = ModularPipelineRuntime.from_adapter(adapter, adapter.load_pipeline())
runtime.materialize_components([adapter.transformer_component_name])
+ install_h3_feed_forward_chunking(runtime.get_component(adapter.transformer_component_name))
return runtime
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
new file mode 100644
index 000000000..82f3fd66d
--- /dev/null
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -0,0 +1,151 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from copy import deepcopy
+
+import pytest
+import torch
+from torch import nn
+
+from flow_factory.models.minimax_h3._chunking import (
+ _ChunkedFeedForward,
+ install_h3_feed_forward_chunking,
+)
+
+
+class SwiGLU(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.proj = nn.Linear(5, 28, bias=False)
+ self.activation = nn.SiLU()
+
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ hidden_states, gate = self.proj(hidden_states).chunk(2, dim=-1)
+ return hidden_states * self.activation(gate)
+
+
+class FeedForwardFake(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.net = nn.ModuleList(
+ [
+ SwiGLU(),
+ nn.Dropout(0.0),
+ nn.Linear(14, 5, bias=False),
+ ]
+ )
+
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ for module in self.net:
+ hidden_states = module(hidden_states)
+ return hidden_states
+
+
+class BlockFake(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.ff = FeedForwardFake()
+
+
+class TransformerFake(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.token_refiner = nn.Module()
+ self.token_refiner.refiner_blocks = nn.ModuleList([BlockFake(), BlockFake()])
+ self.transformer_blocks = nn.ModuleList([BlockFake(), BlockFake(), BlockFake()])
+
+
+def _feed_forwards(transformer: TransformerFake) -> list[nn.Module]:
+ return [
+ *[block.ff for block in transformer.token_refiner.refiner_blocks],
+ *[block.ff for block in transformer.transformer_blocks],
+ ]
+
+
+def test_chunking_preserves_parameter_tree_and_is_idempotent() -> None:
+ transformer = TransformerFake()
+ state = deepcopy(transformer.state_dict())
+ keys_before = tuple(state)
+ parameter_ids_before = {
+ name: id(parameter) for name, parameter in transformer.named_parameters()
+ }
+
+ assert install_h3_feed_forward_chunking(transformer, max_tokens=4) == 5
+ assert install_h3_feed_forward_chunking(transformer, max_tokens=4) == 5
+
+ assert all(isinstance(module, _ChunkedFeedForward) for module in _feed_forwards(transformer))
+ assert tuple(transformer.state_dict()) == keys_before
+ assert {
+ name: id(parameter) for name, parameter in transformer.named_parameters()
+ } == parameter_ids_before
+ transformer.load_state_dict(state, strict=True)
+ assert "transformer_blocks.0.ff.net.0.proj.weight" in transformer.state_dict()
+ assert not any("inner" in name for name, _ in transformer.named_modules())
+
+
+def test_chunking_handles_remainder_and_preserves_forward_backward() -> None:
+ torch.manual_seed(17)
+ direct = TransformerFake().double()
+ chunked = deepcopy(direct)
+ install_h3_feed_forward_chunking(chunked, max_tokens=4)
+ direct_ff = direct.transformer_blocks[0].ff
+ chunked_ff = chunked.transformer_blocks[0].ff
+ chunk_sizes: list[int] = []
+ handle = chunked_ff.net[0].register_forward_pre_hook(
+ lambda _module, inputs: chunk_sizes.append(inputs[0].shape[1])
+ )
+ direct_input = torch.randn(2, 9, 5, dtype=torch.float64, requires_grad=True)
+ chunked_input = direct_input.detach().clone().requires_grad_(True)
+ output_gradient = torch.randn(2, 9, 5, dtype=torch.float64)
+
+ direct_output = direct_ff(direct_input)
+ chunked_output = chunked_ff(chunked_input)
+ direct_output.backward(output_gradient)
+ chunked_output.backward(output_gradient)
+ handle.remove()
+
+ assert chunk_sizes == [4, 4, 1]
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_input.grad, direct_input.grad)
+ for direct_parameter, chunked_parameter in zip(direct_ff.parameters(), chunked_ff.parameters()):
+ torch.testing.assert_close(chunked_parameter.grad, direct_parameter.grad)
+
+
+def test_short_feed_forward_uses_one_execution() -> None:
+ transformer = TransformerFake()
+ install_h3_feed_forward_chunking(transformer, max_tokens=4)
+ feed_forward = transformer.transformer_blocks[0].ff
+ chunk_sizes: list[int] = []
+ handle = feed_forward.net[0].register_forward_pre_hook(
+ lambda _module, inputs: chunk_sizes.append(inputs[0].shape[1])
+ )
+
+ feed_forward(torch.randn(2, 4, 5))
+ handle.remove()
+
+ assert chunk_sizes == [4]
+
+
+@pytest.mark.parametrize("max_tokens", [0, -1, True, 1.5])
+def test_chunking_rejects_invalid_max_tokens(max_tokens: object) -> None:
+ with pytest.raises(ValueError, match="max_tokens expected a positive int"):
+ install_h3_feed_forward_chunking(TransformerFake(), max_tokens=max_tokens)
+
+
+def test_chunking_rejects_conflicting_reinstallation() -> None:
+ transformer = TransformerFake()
+ install_h3_feed_forward_chunking(transformer, max_tokens=4)
+
+ with pytest.raises(ValueError, match="already uses max_tokens=4.*conflicting 8"):
+ install_h3_feed_forward_chunking(transformer, max_tokens=8)
diff --git a/tests/models/minimax_h3/test_workflow_adapters.py b/tests/models/minimax_h3/test_workflow_adapters.py
index 8a3829018..e3ca52398 100644
--- a/tests/models/minimax_h3/test_workflow_adapters.py
+++ b/tests/models/minimax_h3/test_workflow_adapters.py
@@ -38,6 +38,36 @@ class UpstreamSchedulerFake:
"""Represent the lazy upstream scheduler replaced by Flow-Factory."""
+class SwiGLU(nn.Module):
+ """Match the upstream activation class name without adding parameters."""
+
+ def forward(self, value: torch.Tensor) -> torch.Tensor:
+ return value
+
+
+class FeedForwardFake(nn.Module):
+ """Expose the parameter-free upstream ``ff.net`` module structure."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ output = nn.Linear(1, 1, bias=False)
+ output.weight = None
+ self.net = nn.ModuleList([SwiGLU(), nn.Dropout(0.0), output])
+
+ def forward(self, value: torch.Tensor) -> torch.Tensor:
+ for module in self.net:
+ value = module(value)
+ return value
+
+
+class TransformerBlockFake(nn.Module):
+ """Expose one feed-forward child without adding trainable parameters."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.ff = FeedForwardFake()
+
+
class TransformerFake(nn.Module):
"""Provide one parameter for BaseAdapter freeze and precision setup."""
@@ -46,6 +76,9 @@ class TransformerFake(nn.Module):
def __init__(self) -> None:
super().__init__()
self.weight = nn.Parameter(torch.ones(1))
+ self.token_refiner = nn.Module()
+ self.token_refiner.refiner_blocks = nn.ModuleList([TransformerBlockFake()])
+ self.transformer_blocks = nn.ModuleList([TransformerBlockFake()])
self.gradient_checkpointing_calls = 0
def forward(self, value: torch.Tensor) -> torch.Tensor:
@@ -222,8 +255,7 @@ def test_workflow_loader_preserves_hub_source() -> None:
assert isinstance(pipeline, WorkflowPipelineFake)
assert WorkflowPipelineFake.calls == [(model_name_or_path, "t2va")]
assert all(
- spec.pretrained_model_name_or_path == model_name_or_path
- and spec.revision == "main"
+ spec.pretrained_model_name_or_path == model_name_or_path and spec.revision == "main"
for spec in pipeline._component_specs.values()
)
@@ -304,6 +336,12 @@ def test_workflow_adapter_loads_pruned_runtime_and_exact_setup_components(
assert len(adapter.audio_scheduler.timesteps) == 4
assert not hasattr(adapter.pipeline, "unrelated")
assert transformer_name in adapter.component_runtime.materialized_component_names
+ transformer = adapter.get_component(transformer_name)
+ configured_blocks = [
+ *transformer.token_refiner.refiner_blocks,
+ *transformer.transformer_blocks,
+ ]
+ assert all(getattr(block.ff, "max_tokens", None) == 4096 for block in configured_blocks)
opposite = "transformer_ref" if transformer_name == "transformer" else "transformer"
assert opposite not in adapter.component_runtime.declared_component_names
From ed813541b256a5f6df5278286621b923ef33c733 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 23:12:34 +0800
Subject: [PATCH 50/76] [models] fix: tighten H3 feed-forward chunk bound
---
.agents/knowledge/topics/fix_patterns.md | 2 +-
src/flow_factory/models/minimax_h3/_chunking.py | 2 +-
tests/models/minimax_h3/test_workflow_adapters.py | 7 ++++++-
3 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 1028f3853..e21991060 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -539,7 +539,7 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Root Cause**: Diffusers' H3 blocks evaluated every feed-forward projection over the complete
dynamic sequence, and its generic chunk helper rejected non-divisible lengths such as 13,889.
- **Fix**: H3 runtime setup now reuses each existing `ff.net` parameter tree inside a remainder-safe
- 4,096-token executor for both token-refiner and main transformer blocks. Installation precedes
+ 2,048-token executor for both token-refiner and main transformer blocks. Installation precedes
Flow-Factory resume loading, LoRA, checkpointing, and distributed wrapping, so state-dict keys,
parameter identities, and execution policy remain consistent across rollout and training.
- **Lesson**: A token-local operation does not need to inherit the peak allocation of a packed
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index 63dd64f7c..25ef591be 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -21,7 +21,7 @@
import torch
from torch import nn
-H3_MAX_FEED_FORWARD_TOKENS = 4096
+H3_MAX_FEED_FORWARD_TOKENS = 2048
class _ChunkedFeedForward(nn.Module):
diff --git a/tests/models/minimax_h3/test_workflow_adapters.py b/tests/models/minimax_h3/test_workflow_adapters.py
index e3ca52398..14fcdf318 100644
--- a/tests/models/minimax_h3/test_workflow_adapters.py
+++ b/tests/models/minimax_h3/test_workflow_adapters.py
@@ -23,6 +23,7 @@
from accelerate import DistributedType
from flow_factory.models.abc import BaseAdapter
+from flow_factory.models.minimax_h3._chunking import H3_MAX_FEED_FORWARD_TOKENS
from flow_factory.models.minimax_h3.adapters import (
MiniMaxH3FL2VAAdapter,
MiniMaxH3Ref2VAAdapter,
@@ -337,11 +338,15 @@ def test_workflow_adapter_loads_pruned_runtime_and_exact_setup_components(
assert not hasattr(adapter.pipeline, "unrelated")
assert transformer_name in adapter.component_runtime.materialized_component_names
transformer = adapter.get_component(transformer_name)
+ assert H3_MAX_FEED_FORWARD_TOKENS == 2048
configured_blocks = [
*transformer.token_refiner.refiner_blocks,
*transformer.transformer_blocks,
]
- assert all(getattr(block.ff, "max_tokens", None) == 4096 for block in configured_blocks)
+ assert all(
+ getattr(block.ff, "max_tokens", None) == H3_MAX_FEED_FORWARD_TOKENS
+ for block in configured_blocks
+ )
opposite = "transformer_ref" if transformer_name == "transformer" else "transformer"
assert opposite not in adapter.component_runtime.declared_component_names
From 1338bb976c3d776baf0b424064e6e1819f3fe321 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 23:16:24 +0800
Subject: [PATCH 51/76] [models] fix: finalize H3 feed-forward chunk bound
---
.agents/knowledge/topics/fix_patterns.md | 2 +-
src/flow_factory/models/minimax_h3/_chunking.py | 2 +-
tests/models/minimax_h3/test_workflow_adapters.py | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index e21991060..65fe09a21 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -539,7 +539,7 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Root Cause**: Diffusers' H3 blocks evaluated every feed-forward projection over the complete
dynamic sequence, and its generic chunk helper rejected non-divisible lengths such as 13,889.
- **Fix**: H3 runtime setup now reuses each existing `ff.net` parameter tree inside a remainder-safe
- 2,048-token executor for both token-refiner and main transformer blocks. Installation precedes
+ 1,024-token executor for both token-refiner and main transformer blocks. Installation precedes
Flow-Factory resume loading, LoRA, checkpointing, and distributed wrapping, so state-dict keys,
parameter identities, and execution policy remain consistent across rollout and training.
- **Lesson**: A token-local operation does not need to inherit the peak allocation of a packed
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index 25ef591be..5a59339ac 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -21,7 +21,7 @@
import torch
from torch import nn
-H3_MAX_FEED_FORWARD_TOKENS = 2048
+H3_MAX_FEED_FORWARD_TOKENS = 1024
class _ChunkedFeedForward(nn.Module):
diff --git a/tests/models/minimax_h3/test_workflow_adapters.py b/tests/models/minimax_h3/test_workflow_adapters.py
index 14fcdf318..418bd939a 100644
--- a/tests/models/minimax_h3/test_workflow_adapters.py
+++ b/tests/models/minimax_h3/test_workflow_adapters.py
@@ -338,7 +338,7 @@ def test_workflow_adapter_loads_pruned_runtime_and_exact_setup_components(
assert not hasattr(adapter.pipeline, "unrelated")
assert transformer_name in adapter.component_runtime.materialized_component_names
transformer = adapter.get_component(transformer_name)
- assert H3_MAX_FEED_FORWARD_TOKENS == 2048
+ assert H3_MAX_FEED_FORWARD_TOKENS == 1024
configured_blocks = [
*transformer.token_refiner.refiner_blocks,
*transformer.transformer_blocks,
From 7100191754c8a382f50344d24bdc0d8c7096b279 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 23:24:09 +0800
Subject: [PATCH 52/76] [models] fix: bound H3 attention normalization memory
---
.agents/knowledge/topics/fix_patterns.md | 14 +++
.../models/minimax_h3/_chunking.py | 119 +++++++++++++++---
.../models/minimax_h3/workflow.py | 9 +-
tests/models/minimax_h3/test_chunking.py | 85 +++++++++++++
.../minimax_h3/test_workflow_adapters.py | 14 ++-
5 files changed, 222 insertions(+), 19 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 65fe09a21..8bec520ea 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -547,6 +547,20 @@ Based on the fix type, write the fix entry to the appropriate document:
parameter tree and accepting dynamic tail chunks.
- **Related Constraint**: N/A
+### Head-space normalization should not upcast an entire packed sequence
+- **Date**: 2026-08-30
+- **Symptom**: MiniMax H3 Ref2VA FSDP2 GRPO exhausted a 95 GiB device while
+ `attn.norm_q` requested one 380 MiB allocation before reaching the feed-forward layer.
+- **Root Cause**: PyTorch RMSNorm promoted the complete `[1, 13889, 56, 128]` BF16 query to a
+ 379.8 MiB FP32 temporary even though normalization reduces only the final head dimension.
+- **Fix**: H3 runtime setup now reuses each Q/K RMSNorm parameter inside a remainder-safe
+ 1,024-token executor on both repeated block stacks. Parameter identities and
+ `attn.norm_q.weight` / `attn.norm_k.weight` state-dict paths remain unchanged.
+- **Lesson**: Row-local normalization can preserve its exact reduction semantics while bounding
+ the number of independent rows promoted at once. Chunk the non-reduced sequence dimension,
+ never the normalized head dimension.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index 5a59339ac..2c86965bd 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Bound peak memory for token-local MiniMax H3 feed-forward layers."""
+"""Bound peak memory for token-local MiniMax H3 operations."""
from __future__ import annotations
@@ -22,6 +22,7 @@
from torch import nn
H3_MAX_FEED_FORWARD_TOKENS = 1024
+H3_MAX_ATTENTION_NORM_TOKENS = 1024
class _ChunkedFeedForward(nn.Module):
@@ -57,6 +58,40 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
)
+class _ChunkedRMSNorm(nn.RMSNorm):
+ """Bound row-wise RMSNorm temporaries while preserving its parameter path."""
+
+ def __init__(self, norm: nn.RMSNorm, *, max_tokens: int) -> None:
+ nn.Module.__init__(self)
+ self.normalized_shape = norm.normalized_shape
+ self.eps = norm.eps
+ self.elementwise_affine = norm.elementwise_affine
+ self.register_parameter("weight", norm.weight)
+ self.max_tokens = _positive_int(max_tokens, "max_tokens")
+
+ def _forward_chunk(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ return nn.functional.rms_norm(
+ hidden_states,
+ self.normalized_shape,
+ self.weight,
+ self.eps,
+ )
+
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ """Normalize independent token rows without materializing a full FP32 copy."""
+ if hidden_states.ndim < 3:
+ raise ValueError(
+ "MiniMax H3 RMSNorm chunking expected [batch, tokens, ..., hidden] "
+ f"input, received shape={tuple(hidden_states.shape)}"
+ )
+ if hidden_states.shape[1] <= self.max_tokens:
+ return self._forward_chunk(hidden_states)
+ return torch.cat(
+ [self._forward_chunk(chunk) for chunk in hidden_states.split(self.max_tokens, dim=1)],
+ dim=1,
+ )
+
+
def install_h3_feed_forward_chunking(
transformer: nn.Module,
*,
@@ -73,7 +108,7 @@ def install_h3_feed_forward_chunking(
Number of feed-forward layers configured across both stacks.
"""
max_tokens = _positive_int(max_tokens, "max_tokens")
- blocks = tuple(_h3_feed_forward_blocks(transformer))
+ blocks = tuple(_h3_repeated_blocks(transformer))
configured = 0
for name, block in blocks:
feed_forward = getattr(block, "ff", None)
@@ -111,19 +146,7 @@ def install_h3_feed_forward_chunking(
feed_forward.named_buffers(recurse=False)
):
raise TypeError(f"MiniMax H3 {name}.ff expected no direct parameters or buffers")
- hook_fields = (
- "_forward_pre_hooks",
- "_forward_hooks",
- "_backward_pre_hooks",
- "_backward_hooks",
- )
- active_hooks = tuple(field for field in hook_fields if getattr(feed_forward, field, None))
- if active_hooks or getattr(feed_forward, "_hf_hook", None) is not None:
- raise TypeError(
- f"MiniMax H3 {name}.ff must be configured before execution hooks; "
- f"received hooks={active_hooks}, hf_hook="
- f"{type(getattr(feed_forward, '_hf_hook', None)).__name__}"
- )
+ _reject_execution_hooks(feed_forward, f"{name}.ff")
replacement = _ChunkedFeedForward(net, max_tokens=max_tokens)
replacement.train(feed_forward.training)
block.ff = replacement
@@ -131,7 +154,55 @@ def install_h3_feed_forward_chunking(
return configured
-def _h3_feed_forward_blocks(transformer: nn.Module) -> Iterable[tuple[str, nn.Module]]:
+def install_h3_attention_norm_chunking(
+ transformer: nn.Module,
+ *,
+ max_tokens: int = H3_MAX_ATTENTION_NORM_TOKENS,
+) -> int:
+ """Install bounded Q/K head normalization on both H3 repeated stacks."""
+ max_tokens = _positive_int(max_tokens, "max_tokens")
+ configured = 0
+ for block_name, block in _h3_repeated_blocks(transformer):
+ attention = getattr(block, "attn", None)
+ if not isinstance(attention, nn.Module):
+ raise TypeError(
+ f"MiniMax H3 {block_name}.attn expected nn.Module, received "
+ f"{type(attention).__name__}"
+ )
+ for norm_name in ("norm_q", "norm_k"):
+ path = f"{block_name}.attn.{norm_name}"
+ norm = getattr(attention, norm_name, None)
+ if isinstance(norm, _ChunkedRMSNorm):
+ if norm.max_tokens != max_tokens:
+ raise ValueError(
+ f"MiniMax H3 {path} already uses max_tokens={norm.max_tokens}, "
+ f"received conflicting {max_tokens}"
+ )
+ configured += 1
+ continue
+ if not isinstance(norm, nn.RMSNorm):
+ raise TypeError(
+ f"MiniMax H3 {path} expected nn.RMSNorm, received " f"{type(norm).__name__}"
+ )
+ if tuple(norm.named_children()) or tuple(norm.named_buffers(recurse=False)):
+ raise TypeError(f"MiniMax H3 {path} expected no child modules or direct buffers")
+ expected_parameters = ("weight",) if norm.elementwise_affine else ()
+ if (
+ tuple(name for name, _ in norm.named_parameters(recurse=False))
+ != expected_parameters
+ ):
+ raise TypeError(
+ f"MiniMax H3 {path} expected direct parameters={expected_parameters}"
+ )
+ _reject_execution_hooks(norm, path)
+ replacement = _ChunkedRMSNorm(norm, max_tokens=max_tokens)
+ replacement.train(norm.training)
+ setattr(attention, norm_name, replacement)
+ configured += 1
+ return configured
+
+
+def _h3_repeated_blocks(transformer: nn.Module) -> Iterable[tuple[str, nn.Module]]:
token_refiner = getattr(transformer, "token_refiner", None)
stacks = (
("token_refiner.refiner_blocks", getattr(token_refiner, "refiner_blocks", None)),
@@ -147,6 +218,22 @@ def _h3_feed_forward_blocks(transformer: nn.Module) -> Iterable[tuple[str, nn.Mo
yield f"{stack_name}.{index}", block
+def _reject_execution_hooks(module: nn.Module, path: str) -> None:
+ hook_fields = (
+ "_forward_pre_hooks",
+ "_forward_hooks",
+ "_backward_pre_hooks",
+ "_backward_hooks",
+ )
+ active_hooks = tuple(field for field in hook_fields if getattr(module, field, None))
+ if active_hooks or getattr(module, "_hf_hook", None) is not None:
+ raise TypeError(
+ f"MiniMax H3 {path} must be configured before execution hooks; "
+ f"received hooks={active_hooks}, hf_hook="
+ f"{type(getattr(module, '_hf_hook', None)).__name__}"
+ )
+
+
def _positive_int(value: int, field: str) -> int:
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
raise ValueError(f"MiniMax H3 {field} expected a positive int, received {value!r}")
diff --git a/src/flow_factory/models/minimax_h3/workflow.py b/src/flow_factory/models/minimax_h3/workflow.py
index 0b3376247..d0a9b0ea6 100644
--- a/src/flow_factory/models/minimax_h3/workflow.py
+++ b/src/flow_factory/models/minimax_h3/workflow.py
@@ -30,7 +30,10 @@
)
from ...scheduler import MiniMaxH3SDEScheduler, SchedulerGroup
from ..runtime import ModularPipelineRuntime
-from ._chunking import install_h3_feed_forward_chunking
+from ._chunking import (
+ install_h3_attention_norm_chunking,
+ install_h3_feed_forward_chunking,
+)
from ._common import (
build_structured_trajectories,
)
@@ -97,7 +100,9 @@ def build_h3_component_runtime(adapter: Any) -> ModularPipelineRuntime:
validate_h3_target_components(adapter)
runtime = ModularPipelineRuntime.from_adapter(adapter, adapter.load_pipeline())
runtime.materialize_components([adapter.transformer_component_name])
- install_h3_feed_forward_chunking(runtime.get_component(adapter.transformer_component_name))
+ transformer = runtime.get_component(adapter.transformer_component_name)
+ install_h3_feed_forward_chunking(transformer)
+ install_h3_attention_norm_chunking(transformer)
return runtime
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
index 82f3fd66d..85a2eac82 100644
--- a/tests/models/minimax_h3/test_chunking.py
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -17,9 +17,12 @@
import pytest
import torch
from torch import nn
+from torch.nn import functional as F
from flow_factory.models.minimax_h3._chunking import (
_ChunkedFeedForward,
+ _ChunkedRMSNorm,
+ install_h3_attention_norm_chunking,
install_h3_feed_forward_chunking,
)
@@ -55,6 +58,9 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
class BlockFake(nn.Module):
def __init__(self) -> None:
super().__init__()
+ self.attn = nn.Module()
+ self.attn.norm_q = nn.RMSNorm(5)
+ self.attn.norm_k = nn.RMSNorm(5)
self.ff = FeedForwardFake()
@@ -149,3 +155,82 @@ def test_chunking_rejects_conflicting_reinstallation() -> None:
with pytest.raises(ValueError, match="already uses max_tokens=4.*conflicting 8"):
install_h3_feed_forward_chunking(transformer, max_tokens=8)
+
+
+def test_attention_norm_chunking_preserves_parameter_tree_and_is_idempotent() -> None:
+ transformer = TransformerFake()
+ state = deepcopy(transformer.state_dict())
+ keys_before = tuple(state)
+ parameter_ids_before = {
+ name: id(parameter) for name, parameter in transformer.named_parameters()
+ }
+
+ assert install_h3_attention_norm_chunking(transformer, max_tokens=4) == 10
+ assert install_h3_attention_norm_chunking(transformer, max_tokens=4) == 10
+
+ norms = [
+ norm
+ for block in (
+ *transformer.token_refiner.refiner_blocks,
+ *transformer.transformer_blocks,
+ )
+ for norm in (block.attn.norm_q, block.attn.norm_k)
+ ]
+ assert all(isinstance(norm, _ChunkedRMSNorm) for norm in norms)
+ assert tuple(transformer.state_dict()) == keys_before
+ assert {
+ name: id(parameter) for name, parameter in transformer.named_parameters()
+ } == parameter_ids_before
+ transformer.load_state_dict(state, strict=True)
+ assert "transformer_blocks.0.attn.norm_q.weight" in transformer.state_dict()
+ assert not any("inner" in name for name, _ in transformer.named_modules())
+
+
+def test_attention_norm_chunking_preserves_remainder_forward_backward(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ torch.manual_seed(23)
+ direct = TransformerFake().double()
+ chunked = deepcopy(direct)
+ install_h3_attention_norm_chunking(chunked, max_tokens=4)
+ direct_norm = direct.transformer_blocks[0].attn.norm_q
+ chunked_norm = chunked.transformer_blocks[0].attn.norm_q
+ direct_input = torch.randn(2, 9, 3, 5, dtype=torch.float64, requires_grad=True)
+ chunked_input = direct_input.detach().clone().requires_grad_(True)
+ output_gradient = torch.randn(2, 9, 3, 5, dtype=torch.float64)
+
+ direct_output = direct_norm(direct_input)
+ direct_output.backward(output_gradient)
+ chunk_sizes: list[int] = []
+ direct_rms_norm = F.rms_norm
+
+ def recording_rms_norm(hidden_states, normalized_shape, weight=None, eps=None):
+ chunk_sizes.append(hidden_states.shape[1])
+ return direct_rms_norm(hidden_states, normalized_shape, weight, eps)
+
+ monkeypatch.setattr(F, "rms_norm", recording_rms_norm)
+ chunked_output = chunked_norm(chunked_input)
+ chunked_output.backward(output_gradient)
+
+ assert chunk_sizes == [4, 4, 1]
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_input.grad, direct_input.grad)
+ torch.testing.assert_close(chunked_norm.weight.grad, direct_norm.weight.grad)
+
+
+def test_attention_norm_chunking_preserves_bfloat16_dtype() -> None:
+ transformer = TransformerFake().bfloat16()
+ direct = transformer.transformer_blocks[0].attn.norm_q(torch.randn(1, 7, 3, 5).bfloat16())
+ install_h3_attention_norm_chunking(transformer, max_tokens=4)
+
+ chunked = transformer.transformer_blocks[0].attn.norm_q(torch.randn(1, 7, 3, 5).bfloat16())
+
+ assert chunked.dtype == direct.dtype == torch.bfloat16
+
+
+def test_attention_norm_chunking_rejects_conflicting_reinstallation() -> None:
+ transformer = TransformerFake()
+ install_h3_attention_norm_chunking(transformer, max_tokens=4)
+
+ with pytest.raises(ValueError, match="already uses max_tokens=4.*conflicting 8"):
+ install_h3_attention_norm_chunking(transformer, max_tokens=8)
diff --git a/tests/models/minimax_h3/test_workflow_adapters.py b/tests/models/minimax_h3/test_workflow_adapters.py
index 418bd939a..941a8983c 100644
--- a/tests/models/minimax_h3/test_workflow_adapters.py
+++ b/tests/models/minimax_h3/test_workflow_adapters.py
@@ -23,7 +23,10 @@
from accelerate import DistributedType
from flow_factory.models.abc import BaseAdapter
-from flow_factory.models.minimax_h3._chunking import H3_MAX_FEED_FORWARD_TOKENS
+from flow_factory.models.minimax_h3._chunking import (
+ H3_MAX_ATTENTION_NORM_TOKENS,
+ H3_MAX_FEED_FORWARD_TOKENS,
+)
from flow_factory.models.minimax_h3.adapters import (
MiniMaxH3FL2VAAdapter,
MiniMaxH3Ref2VAAdapter,
@@ -66,6 +69,9 @@ class TransformerBlockFake(nn.Module):
def __init__(self) -> None:
super().__init__()
+ self.attn = nn.Module()
+ self.attn.norm_q = nn.RMSNorm(1, elementwise_affine=False)
+ self.attn.norm_k = nn.RMSNorm(1, elementwise_affine=False)
self.ff = FeedForwardFake()
@@ -339,6 +345,7 @@ def test_workflow_adapter_loads_pruned_runtime_and_exact_setup_components(
assert transformer_name in adapter.component_runtime.materialized_component_names
transformer = adapter.get_component(transformer_name)
assert H3_MAX_FEED_FORWARD_TOKENS == 1024
+ assert H3_MAX_ATTENTION_NORM_TOKENS == 1024
configured_blocks = [
*transformer.token_refiner.refiner_blocks,
*transformer.transformer_blocks,
@@ -347,6 +354,11 @@ def test_workflow_adapter_loads_pruned_runtime_and_exact_setup_components(
getattr(block.ff, "max_tokens", None) == H3_MAX_FEED_FORWARD_TOKENS
for block in configured_blocks
)
+ assert all(
+ getattr(block.attn.norm_q, "max_tokens", None) == H3_MAX_ATTENTION_NORM_TOKENS
+ and getattr(block.attn.norm_k, "max_tokens", None) == H3_MAX_ATTENTION_NORM_TOKENS
+ for block in configured_blocks
+ )
opposite = "transformer_ref" if transformer_name == "transformer" else "transformer"
assert opposite not in adapter.component_runtime.declared_component_names
From 9a4735a357de5b6ae37caf0ecfdee65f205cad17 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Sun, 30 Aug 2026 23:41:17 +0800
Subject: [PATCH 53/76] [models] fix: checkpoint H3 feed-forward chunks
---
.agents/knowledge/topics/fix_patterns.md | 16 ++++
.../models/minimax_h3/_chunking.py | 17 +++-
tests/models/minimax_h3/test_chunking.py | 94 ++++++++++++++++++-
3 files changed, 125 insertions(+), 2 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 8bec520ea..f45389a30 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -561,6 +561,22 @@ Based on the fix type, write the fix entry to the appropriate document:
never the normalized head dimension.
- **Related Constraint**: N/A
+### Outer activation checkpoint replay can retain every inner token chunk
+- **Date**: 2026-08-30
+- **Symptom**: MiniMax H3 Ref2VA ZeRO-2 offline DPO reached backward replay but exhausted
+ a 95 GiB device when the final feed-forward chunk requested a 28 MiB SiLU allocation.
+- **Root Cause**: The block-level non-reentrant checkpoint replay rebuilt one autograd graph
+ containing the intermediates from every sequential feed-forward chunk. Smaller chunks reduced
+ each allocation but did not bound their cumulative saved activations.
+- **Fix**: Long, grad-enabled H3 feed-forward chunks now use nested non-reentrant activation
+ checkpoints. Each chunk is replayed independently during backward; no-grad rollout and short
+ sequences retain their direct execution paths. Checkpoint replay preserves RNG state so later
+ parameter-efficient adapters cannot silently change stochastic-gradient semantics.
+- **Lesson**: Splitting a local operator bounds forward temporaries but not necessarily backward
+ replay state. When an outer checkpoint recomputes a sequence of chunks, checkpoint each chunk
+ as the lifetime boundary for its saved activations.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index 2c86965bd..779f58f73 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -20,6 +20,7 @@
import torch
from torch import nn
+from torch.utils.checkpoint import checkpoint
H3_MAX_FEED_FORWARD_TOKENS = 1024
H3_MAX_ATTENTION_NORM_TOKENS = 1024
@@ -52,8 +53,22 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
)
if hidden_states.shape[1] <= self.max_tokens:
return self._forward_chunk(hidden_states)
+ chunks = hidden_states.split(self.max_tokens, dim=1)
+ if torch.is_grad_enabled():
+ return torch.cat(
+ [
+ checkpoint(
+ self._forward_chunk,
+ chunk,
+ use_reentrant=False,
+ preserve_rng_state=True,
+ )
+ for chunk in chunks
+ ],
+ dim=1,
+ )
return torch.cat(
- [self._forward_chunk(chunk) for chunk in hidden_states.split(self.max_tokens, dim=1)],
+ [self._forward_chunk(chunk) for chunk in chunks],
dim=1,
)
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
index 85a2eac82..08ef21558 100644
--- a/tests/models/minimax_h3/test_chunking.py
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -18,6 +18,7 @@
import torch
from torch import nn
from torch.nn import functional as F
+from torch.utils.checkpoint import checkpoint
from flow_factory.models.minimax_h3._chunking import (
_ChunkedFeedForward,
@@ -121,7 +122,8 @@ def test_chunking_handles_remainder_and_preserves_forward_backward() -> None:
chunked_output.backward(output_gradient)
handle.remove()
- assert chunk_sizes == [4, 4, 1]
+ assert chunk_sizes[:3] == [4, 4, 1]
+ assert sorted(chunk_sizes[3:]) == [1, 4, 4]
torch.testing.assert_close(chunked_output, direct_output)
torch.testing.assert_close(chunked_input.grad, direct_input.grad)
for direct_parameter, chunked_parameter in zip(direct_ff.parameters(), chunked_ff.parameters()):
@@ -143,6 +145,96 @@ def test_short_feed_forward_uses_one_execution() -> None:
assert chunk_sizes == [4]
+def test_chunking_preserves_nested_checkpoint_backward() -> None:
+ torch.manual_seed(19)
+ direct = TransformerFake().double()
+ chunked = deepcopy(direct)
+ install_h3_feed_forward_chunking(chunked, max_tokens=4)
+ direct_ff = direct.transformer_blocks[0].ff
+ chunked_ff = chunked.transformer_blocks[0].ff
+ direct_input = torch.randn(2, 9, 5, dtype=torch.float64, requires_grad=True)
+ chunked_input = direct_input.detach().clone().requires_grad_(True)
+ output_gradient = torch.randn(2, 9, 5, dtype=torch.float64)
+
+ direct_output = direct_ff(direct_input)
+ chunked_output = checkpoint(chunked_ff, chunked_input, use_reentrant=False)
+ direct_output.backward(output_gradient)
+ chunked_output.backward(output_gradient)
+
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_input.grad, direct_input.grad)
+ for direct_parameter, chunked_parameter in zip(direct_ff.parameters(), chunked_ff.parameters()):
+ torch.testing.assert_close(chunked_parameter.grad, direct_parameter.grad)
+
+
+def test_chunking_checkpoints_only_long_grad_enabled_chunks(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ transformer = TransformerFake()
+ install_h3_feed_forward_chunking(transformer, max_tokens=4)
+ feed_forward = transformer.transformer_blocks[0].ff
+ calls: list[tuple[int, bool, bool]] = []
+
+ def recording_checkpoint(function, chunk, *, use_reentrant, preserve_rng_state):
+ calls.append((chunk.shape[1], use_reentrant, preserve_rng_state))
+ return function(chunk)
+
+ monkeypatch.setattr(
+ "flow_factory.models.minimax_h3._chunking.checkpoint",
+ recording_checkpoint,
+ )
+
+ feed_forward(torch.randn(2, 9, 5, requires_grad=True))
+ with torch.no_grad():
+ feed_forward(torch.randn(2, 9, 5))
+ feed_forward(torch.randn(2, 4, 5, requires_grad=True))
+
+ assert calls == [(4, False, True), (4, False, True), (1, False, True)]
+
+
+def test_chunking_supports_frozen_input_with_trainable_parameters() -> None:
+ direct = TransformerFake().double()
+ chunked = deepcopy(direct)
+ install_h3_feed_forward_chunking(chunked, max_tokens=4)
+ direct_ff = direct.transformer_blocks[0].ff
+ chunked_ff = chunked.transformer_blocks[0].ff
+ for parameter in direct_ff.parameters():
+ parameter.requires_grad_(False)
+ for parameter in chunked_ff.parameters():
+ parameter.requires_grad_(False)
+ direct_ff.net[2].weight.requires_grad_(True)
+ chunked_ff.net[2].weight.requires_grad_(True)
+ hidden_states = torch.randn(2, 9, 5, dtype=torch.float64)
+
+ direct_output = direct_ff(hidden_states)
+ chunked_output = chunked_ff(hidden_states)
+ direct_output.sum().backward()
+ chunked_output.sum().backward()
+
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_ff.net[2].weight.grad, direct_ff.net[2].weight.grad)
+
+
+def test_nested_chunk_checkpoint_preserves_cpu_autocast_dtype() -> None:
+ transformer = TransformerFake()
+ install_h3_feed_forward_chunking(transformer, max_tokens=4)
+ feed_forward = transformer.transformer_blocks[0].ff
+ hidden_states = torch.randn(2, 9, 5, requires_grad=True)
+ projected_dtypes: list[torch.dtype] = []
+ handle = feed_forward.net[2].register_forward_pre_hook(
+ lambda _module, inputs: projected_dtypes.append(inputs[0].dtype)
+ )
+
+ with torch.autocast("cpu", dtype=torch.bfloat16):
+ output = checkpoint(feed_forward, hidden_states, use_reentrant=False)
+ output.float().sum().backward()
+ handle.remove()
+
+ assert output.dtype == torch.bfloat16
+ assert len(projected_dtypes) >= 6
+ assert set(projected_dtypes) == {torch.bfloat16}
+
+
@pytest.mark.parametrize("max_tokens", [0, -1, True, 1.5])
def test_chunking_rejects_invalid_max_tokens(max_tokens: object) -> None:
with pytest.raises(ValueError, match="max_tokens expected a positive int"):
From 55aa8f31a4796330309c49a0b3ab484fafe59a7a Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 00:12:18 +0800
Subject: [PATCH 54/76] [loading,models] fix: bound H3 FSDP2 gather memory
---
.agents/knowledge/topics/fix_patterns.md | 16 ++++
src/flow_factory/loading/backend.py | 47 +++++++++++
src/flow_factory/models/abc.py | 3 +
.../models/minimax_h3/adapters.py | 6 ++
tests/loading/test_backend_runtime.py | 81 ++++++++++++++++++-
.../minimax_h3/test_workflow_adapters.py | 13 +++
6 files changed, 164 insertions(+), 2 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index f45389a30..a06d2e996 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -577,6 +577,22 @@ Based on the fix type, write the fix entry to the appropriate document:
as the lifetime boundary for its saved activations.
- **Related Constraint**: N/A
+### FSDP2 overlap can retain an all-gather buffer beyond a memory-tight block
+- **Date**: 2026-08-30
+- **Symptom**: MiniMax H3 Ref2VA FSDP2 GRPO exhausted a 95 GiB device when a block
+ pre-forward all-gather requested 1.21 GiB with only 1.03 GiB free.
+- **Root Cause**: Each H3 transformer block formed one 1.21 GiB gather unit, while FSDP2's default
+ implicit overlap also retained the current raw gather result through the next block's copy-in.
+ The long Ref2VA packed sequence left too little headroom for either lifetime.
+- **Fix**: Ref2VA extends the FSDP2 wrap policy with its attention, chunked feed-forward, and
+ 496 MiB AdaLN modulation modules, splitting each block into call-ordered gather units. It also
+ opts into default-stream unshard immediately after distributed preparation so each raw gather
+ result is released after copy-out, trading communication overlap for lower peak allocation.
+- **Lesson**: When model activations nearly fill a device, communication overlap is also a memory
+ policy. Apply the backend's explicit lifetime control at the prepared root instead of adding
+ allocator flushes or weakening model semantics.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/loading/backend.py b/src/flow_factory/loading/backend.py
index 49c2c45d6..2f68133ca 100644
--- a/src/flow_factory/loading/backend.py
+++ b/src/flow_factory/loading/backend.py
@@ -120,6 +120,53 @@ def load_scope(self, role: ComponentRole) -> Iterator[None]:
else:
os.environ["FSDP_CPU_RAM_EFFICIENT_LOADING"] = previous
+ def prepare(self, *objects: Any) -> Any:
+ """Prepare roots and apply adapter-requested FSDP2 communication policy."""
+ self._extend_fsdp2_wrap_policy(objects)
+ prepared = super().prepare(*objects)
+ plugin = self.accelerator.state.fsdp_plugin
+ if (getattr(plugin, "fsdp_version", 1) or 1) < 2 or not getattr(
+ self.adapter, "fsdp2_use_default_stream_unshard", False
+ ):
+ return prepared
+
+ prepared_objects = prepared if isinstance(prepared, (list, tuple)) else (prepared,)
+ configured = []
+ for original, candidate in zip(objects, prepared_objects):
+ if not isinstance(original, nn.Module):
+ continue
+ configure = getattr(candidate, "_set_unshard_async_op", None)
+ if not callable(configure):
+ raise TypeError(
+ "FSDP2 default-stream unshard requires prepared modules to expose "
+ f"_set_unshard_async_op(), received {type(candidate).__name__}"
+ )
+ configure(True)
+ configured.append(type(candidate).__name__)
+ if not configured:
+ raise TypeError("FSDP2 default-stream unshard requested without a prepared module")
+ logger.info("Enabled FSDP2 default-stream unshard for roots: %s", configured)
+ return prepared
+
+ def _extend_fsdp2_wrap_policy(self, objects: Sequence[Any]) -> None:
+ plugin = self.accelerator.state.fsdp_plugin
+ additional = tuple(getattr(self.adapter, "fsdp2_additional_wrap_module_names", ()))
+ if (getattr(plugin, "fsdp_version", 1) or 1) < 2 or not additional:
+ return
+ configured = getattr(plugin, "transformer_cls_names_to_wrap", None)
+ if configured is None:
+ configured = [
+ name
+ for candidate in objects
+ if isinstance(candidate, nn.Module)
+ for name in (getattr(candidate, "_no_split_modules", None) or ())
+ ]
+ plugin.transformer_cls_names_to_wrap = list(dict.fromkeys([*configured, *additional]))
+ logger.info(
+ "Extended FSDP2 transformer wrap modules: %s",
+ plugin.transformer_cls_names_to_wrap,
+ )
+
def bootstrap_targets(self) -> None:
if not self.cpu_ram_efficient_loading or self.accelerator.num_processes <= 1:
return
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index e2abfef2d..749ea103e 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -233,6 +233,9 @@ class BaseAdapter(ABC):
# ``cache_context``. The rollout cache accelerator rejects the default.
supports_diffusers_cache: ClassVar[bool] = False
supports_fsdp2_cpu_efficient_loading: ClassVar[bool] = False
+ # Opt in only when FSDP2 communication overlap exceeds the model's activation headroom.
+ fsdp2_use_default_stream_unshard: ClassVar[bool] = False
+ fsdp2_additional_wrap_module_names: ClassVar[Tuple[str, ...]] = ()
supports_ordered_references: ClassVar[bool] = False
preprocess_cache_fields: ClassVar[frozenset[str]] = frozenset()
preprocess_cache_version: ClassVar[str] = ""
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index a9c7e74bc..ac16a9c04 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -354,6 +354,12 @@ class MiniMaxH3Ref2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
workflow: ClassVar[str] = "ref2va"
transformer_component_name: ClassVar[str] = "transformer_ref"
+ fsdp2_use_default_stream_unshard: ClassVar[bool] = True
+ fsdp2_additional_wrap_module_names: ClassVar[Tuple[str, ...]] = (
+ "_ChunkedFeedForward",
+ "MiniMaxH3AdaLayerNormModulation",
+ "MiniMaxH3Attention",
+ )
supports_ordered_references: ClassVar[bool] = True
preprocessing_modules: ClassVar[List[str]] = [
"image_processor",
diff --git a/tests/loading/test_backend_runtime.py b/tests/loading/test_backend_runtime.py
index 6af076a83..14a8ed357 100644
--- a/tests/loading/test_backend_runtime.py
+++ b/tests/loading/test_backend_runtime.py
@@ -22,14 +22,18 @@ class _AcceleratorFake:
num_processes = 2
process_index = 0
- def __init__(self, *, efficient: bool) -> None:
+ def __init__(self, *, efficient: bool, fsdp_version: int = 2) -> None:
self.state = SimpleNamespace(
fsdp_plugin=SimpleNamespace(
- fsdp_version=2,
+ fsdp_version=fsdp_version,
cpu_ram_efficient_loading=efficient,
+ transformer_cls_names_to_wrap=None,
)
)
+ def prepare(self, *objects):
+ return list(objects) if len(objects) > 1 else objects[0]
+
def wait_for_everyone(self) -> None:
return None
@@ -105,6 +109,79 @@ def test_reward_scope_restores_target_loading_environment(
assert os.environ["FSDP_CPU_RAM_EFFICIENT_LOADING"] == "true"
+class _FSDPModuleFake(torch.nn.Linear):
+ def __init__(self) -> None:
+ super().__init__(2, 2)
+ self.unshard_async_op_calls = []
+
+ def _set_unshard_async_op(self, enabled: bool) -> None:
+ self.unshard_async_op_calls.append(enabled)
+
+
+def test_prepare_enables_adapter_requested_fsdp2_default_stream_unshard() -> None:
+ module = _FSDPModuleFake()
+ optimizer = object()
+ runtime = FSDPBackendLoadRuntime(
+ _AcceleratorFake(efficient=True),
+ _plan(),
+ SimpleNamespace(fsdp2_use_default_stream_unshard=True),
+ )
+
+ prepared = runtime.prepare(module, optimizer)
+
+ assert prepared == [module, optimizer]
+ assert module.unshard_async_op_calls == [True]
+
+
+def test_prepare_extends_fsdp2_wrap_policy_before_distributed_preparation() -> None:
+ module = _FSDPModuleFake()
+ module._no_split_modules = ["TransformerBlock"]
+ accelerator = _AcceleratorFake(efficient=True)
+ runtime = FSDPBackendLoadRuntime(
+ accelerator,
+ _plan(),
+ SimpleNamespace(
+ fsdp2_additional_wrap_module_names=("ChunkedFeedForward",),
+ ),
+ )
+
+ assert runtime.prepare(module) is module
+ assert accelerator.state.fsdp_plugin.transformer_cls_names_to_wrap == [
+ "TransformerBlock",
+ "ChunkedFeedForward",
+ ]
+
+
+@pytest.mark.parametrize(
+ ("fsdp_version", "requested"),
+ [(1, True), (2, False)],
+)
+def test_prepare_preserves_default_unshard_without_fsdp2_adapter_opt_in(
+ fsdp_version: int,
+ requested: bool,
+) -> None:
+ module = _FSDPModuleFake()
+ runtime = FSDPBackendLoadRuntime(
+ _AcceleratorFake(efficient=True, fsdp_version=fsdp_version),
+ _plan(),
+ SimpleNamespace(fsdp2_use_default_stream_unshard=requested),
+ )
+
+ assert runtime.prepare(module) is module
+ assert module.unshard_async_op_calls == []
+
+
+def test_prepare_rejects_missing_fsdp2_default_stream_unshard_api() -> None:
+ runtime = FSDPBackendLoadRuntime(
+ _AcceleratorFake(efficient=True),
+ _plan(),
+ SimpleNamespace(fsdp2_use_default_stream_unshard=True),
+ )
+
+ with pytest.raises(TypeError, match="requires prepared modules.*_set_unshard_async_op"):
+ runtime.prepare(torch.nn.Linear(2, 2))
+
+
def test_physical_target_root_is_not_selected_as_auxiliary() -> None:
adapter = SimpleNamespace(
_resolve_component_names=lambda components: list(components),
diff --git a/tests/models/minimax_h3/test_workflow_adapters.py b/tests/models/minimax_h3/test_workflow_adapters.py
index 941a8983c..33bea489d 100644
--- a/tests/models/minimax_h3/test_workflow_adapters.py
+++ b/tests/models/minimax_h3/test_workflow_adapters.py
@@ -42,6 +42,19 @@ class UpstreamSchedulerFake:
"""Represent the lazy upstream scheduler replaced by Flow-Factory."""
+def test_only_ref2va_requests_fsdp2_default_stream_unshard() -> None:
+ assert MiniMaxH3Ref2VAAdapter.fsdp2_use_default_stream_unshard
+ assert MiniMaxH3Ref2VAAdapter.fsdp2_additional_wrap_module_names == (
+ "_ChunkedFeedForward",
+ "MiniMaxH3AdaLayerNormModulation",
+ "MiniMaxH3Attention",
+ )
+ assert not MiniMaxH3T2VAAdapter.fsdp2_use_default_stream_unshard
+ assert not MiniMaxH3T2VAAdapter.fsdp2_additional_wrap_module_names
+ assert not MiniMaxH3FL2VAAdapter.fsdp2_use_default_stream_unshard
+ assert not MiniMaxH3FL2VAAdapter.fsdp2_additional_wrap_module_names
+
+
class SwiGLU(nn.Module):
"""Match the upstream activation class name without adding parameters."""
From 12e574d6d6ade2289f54a27b8c23587e72f0adb8 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 00:24:40 +0800
Subject: [PATCH 55/76] [models] fix: bound H3 LoRA projection memory
---
.agents/knowledge/topics/fix_patterns.md | 16 ++
.../models/minimax_h3/_chunking.py | 130 ++++++++++++++++
.../models/minimax_h3/adapters.py | 48 ++++++
tests/models/minimax_h3/test_chunking.py | 144 ++++++++++++++++++
4 files changed, 338 insertions(+)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index a06d2e996..0861496c9 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -593,6 +593,22 @@ Based on the fix type, write the fix entry to the appropriate document:
allocator flushes or weakening model semantics.
- **Related Constraint**: N/A
+### PEFT projections should not materialize a full-sequence LoRA branch
+- **Date**: 2026-08-31
+- **Symptom**: MiniMax H3 Ref2VA FSDP2 GRPO passed every parameter all-gather but exhausted a
+ 95 GiB device when one adapted K projection requested a 190 MiB LoRA output with only
+ 114--134 MiB free.
+- **Root Cause**: PEFT evaluated the base projection and low-rank branch over the complete 13,889-token
+ packed sequence before adding them, so two full projection outputs overlapped at the memory peak.
+- **Fix**: Ref2VA FSDP2 changes each existing adapted Q/K/V/output PEFT Linear to a token-chunked
+ forward after LoRA injection. The complete PEFT contract runs per chunk and writes directly into
+ one preallocated final output, preserving module and parameter identities, hooks, adapter behavior,
+ and state-dict paths without a full-size concatenation copy.
+- **Lesson**: Bound parameter-efficient adaptation at the outer adapted-module boundary. Chunking only
+ the frozen base layer leaves the adapter branch unbounded, while concatenating chunk outputs can
+ recreate the same peak through an avoidable second full-size result.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index 779f58f73..dc65a853e 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -19,11 +19,13 @@
from typing import Iterable
import torch
+from peft.tuners.lora.layer import Linear as LoraLinear
from torch import nn
from torch.utils.checkpoint import checkpoint
H3_MAX_FEED_FORWARD_TOKENS = 1024
H3_MAX_ATTENTION_NORM_TOKENS = 1024
+H3_MAX_LORA_PROJECTION_TOKENS = 1024
class _ChunkedFeedForward(nn.Module):
@@ -107,6 +109,42 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
)
+class _ChunkedLoraLinear(LoraLinear):
+ """Bound PEFT projection temporaries without replacing its module or state tree."""
+
+ flow_factory_max_tokens: int
+
+ def forward(self, hidden_states: torch.Tensor, *args, **kwargs) -> torch.Tensor:
+ """Run the complete PEFT linear contract over sequence chunks."""
+ if kwargs.get("alora_offsets") is not None:
+ raise ValueError("MiniMax H3 LoRA projection chunking does not support aLoRA offsets")
+ max_tokens = self.flow_factory_max_tokens
+ if hidden_states.ndim < 3 or hidden_states.shape[1] <= max_tokens:
+ return super().forward(hidden_states, *args, **kwargs)
+
+ forward_chunk = super().forward
+ output = None
+ offset = 0
+ for chunk in hidden_states.split(max_tokens, dim=1):
+ chunk_output = forward_chunk(chunk, *args, **kwargs)
+ if chunk_output.shape[:-1] != chunk.shape[:-1]:
+ raise RuntimeError(
+ "MiniMax H3 LoRA projection must preserve input prefix dimensions, "
+ f"received input={tuple(chunk.shape)}, output={tuple(chunk_output.shape)}"
+ )
+ if output is None:
+ output = chunk_output.new_empty((*hidden_states.shape[:-1], chunk_output.shape[-1]))
+ output.narrow(1, offset, chunk_output.shape[1]).copy_(chunk_output)
+ offset += chunk_output.shape[1]
+
+ if output is None or offset != hidden_states.shape[1]:
+ raise RuntimeError(
+ "MiniMax H3 LoRA projection failed to assemble every input token, "
+ f"received input_tokens={hidden_states.shape[1]}, output_tokens={offset}"
+ )
+ return output
+
+
def install_h3_feed_forward_chunking(
transformer: nn.Module,
*,
@@ -217,6 +255,98 @@ def install_h3_attention_norm_chunking(
return configured
+def install_h3_lora_projection_chunking(
+ transformer: nn.Module,
+ *,
+ max_tokens: int = H3_MAX_LORA_PROJECTION_TOKENS,
+) -> int:
+ """Bound adapted H3 attention projections while preserving PEFT ownership.
+
+ The installer runs after PEFT injection and before distributed preparation. It
+ changes only the Python forward implementation on each existing PEFT Linear;
+ parameters, children, hooks, and state-dict paths remain owned by that same
+ module object.
+
+ Returns:
+ Number of adapted Q/K/V/output projections configured across both stacks.
+ """
+ max_tokens = _positive_int(max_tokens, "max_tokens")
+ configured = 0
+ for block_name, block in _h3_repeated_blocks(transformer):
+ attention = getattr(block, "attn", None)
+ if not isinstance(attention, nn.Module):
+ raise TypeError(
+ f"MiniMax H3 {block_name}.attn expected nn.Module, received "
+ f"{type(attention).__name__}"
+ )
+ if getattr(attention, "fused_projections", False):
+ raise TypeError(
+ f"MiniMax H3 {block_name}.attn must install LoRA chunking before "
+ "fusing its projections"
+ )
+ to_out = getattr(attention, "to_out", None)
+ if not isinstance(to_out, nn.ModuleList) or len(to_out) != 2:
+ raise TypeError(
+ f"MiniMax H3 {block_name}.attn.to_out expected two-entry ModuleList, "
+ f"received {type(to_out).__name__}"
+ )
+ projections = (
+ ("to_q", getattr(attention, "to_q", None)),
+ ("to_k", getattr(attention, "to_k", None)),
+ ("to_v", getattr(attention, "to_v", None)),
+ ("to_out.0", to_out[0]),
+ )
+ for projection_name, projection in projections:
+ path = f"{block_name}.attn.{projection_name}"
+ if isinstance(projection, _ChunkedLoraLinear):
+ if projection.flow_factory_max_tokens != max_tokens:
+ raise ValueError(
+ f"MiniMax H3 {path} already uses max_tokens="
+ f"{projection.flow_factory_max_tokens}, received conflicting "
+ f"{max_tokens}"
+ )
+ configured += 1
+ continue
+ if not isinstance(projection, LoraLinear):
+ continue
+ if type(projection) is not LoraLinear:
+ raise TypeError(
+ f"MiniMax H3 {path} expected the standard PEFT Linear before "
+ f"chunking, received {type(projection).__name__}"
+ )
+ if "forward" in projection.__dict__:
+ raise TypeError(
+ f"MiniMax H3 {path} must not shadow PEFT Linear.forward on the instance"
+ )
+ if getattr(projection, "_compiled_call_impl", None) is not None:
+ raise TypeError(
+ f"MiniMax H3 {path} must install LoRA chunking before torch.compile"
+ )
+ if type(getattr(projection, "base_layer", None)) is not nn.Linear:
+ raise TypeError(
+ f"MiniMax H3 {path} expected an exact nn.Linear base layer, received "
+ f"{type(getattr(projection, 'base_layer', None)).__name__}"
+ )
+ if any(getattr(projection, "use_dora", {}).values()):
+ raise TypeError(f"MiniMax H3 {path} LoRA chunking does not support DoRA")
+ if getattr(projection, "lora_variant", {}):
+ raise TypeError(f"MiniMax H3 {path} LoRA chunking supports only vanilla LoRA")
+ for adapter_name, dropout in projection.lora_dropout.items():
+ if isinstance(dropout, nn.Identity) or (
+ isinstance(dropout, nn.Dropout) and dropout.p == 0.0
+ ):
+ continue
+ raise TypeError(
+ f"MiniMax H3 {path} LoRA chunking requires zero dropout, received "
+ f"adapter={adapter_name!r}, dropout={dropout!r}"
+ )
+ _reject_execution_hooks(projection, path)
+ projection.__class__ = _ChunkedLoraLinear
+ projection.flow_factory_max_tokens = max_tokens
+ configured += 1
+ return configured
+
+
def _h3_repeated_blocks(transformer: nn.Module) -> Iterable[tuple[str, nn.Module]]:
token_refiner = getattr(transformer, "token_refiner", None)
stacks = (
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index ac16a9c04..f84c386b4 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -37,6 +37,7 @@
StackedSampleBatch,
)
from ...scheduler import MiniMaxH3SDEScheduler, SchedulerGroup
+from ...utils.logger_utils import setup_logger
from ..abc import BaseAdapter
from ..checkpointing import CheckpointUnit
from ..output_state import DecodedMediaBatch, EncodedOutputState, OutputStateCodec
@@ -46,6 +47,10 @@
audio_video_output_contract,
)
from ..runtime import ModularPipelineRuntime
+from ._chunking import (
+ H3_MAX_LORA_PROJECTION_TOKENS,
+ install_h3_lora_projection_chunking,
+)
from ._common import apply_forward_process_noise, draw_forward_process_noise
from ._condition import MiniMaxH3ConditionStatePreparer
from ._output import MiniMaxH3AVOutputCodec, validate_h3_encoded_output_geometry
@@ -65,6 +70,7 @@
_H3_PREPROCESS_CACHE_FIELDS = frozenset({"height", "width", "num_frames"})
_H3_PREPROCESS_CACHE_VERSION = "minimax-h3-v2"
+logger = setup_logger(__name__)
_H3_OPTIONAL_AUDIO_REFERENCE_FORMAT = MediaFormat(
type=MediaType.AUDIO,
fps=RateRequirement.NOT_APPLICABLE,
@@ -375,3 +381,45 @@ class MiniMaxH3Ref2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
"video_processor",
"audio_vae",
]
+
+ def apply_lora(
+ self,
+ target_modules: Union[str, List[str]],
+ components: Union[str, List[str]] = "transformer",
+ overwrite: bool = False,
+ ) -> Any:
+ """Apply PEFT and bound Ref2VA projection memory under FSDP2."""
+ component_names = (components,) if isinstance(components, str) else tuple(components)
+ result = super().apply_lora(
+ target_modules=target_modules,
+ components=components,
+ overwrite=overwrite,
+ )
+ if (
+ not result
+ or not self._is_fsdp2()
+ or self.transformer_component_name not in component_names
+ ):
+ return result
+
+ component = self.get_component(self.transformer_component_name)
+ get_base_model = getattr(component, "get_base_model", None)
+ if not callable(get_base_model):
+ raise TypeError(
+ "MiniMax H3 Ref2VA FSDP2 LoRA chunking expected a PEFT component "
+ f"with get_base_model(), received {type(component).__name__}"
+ )
+ transformer = get_base_model()
+ if not isinstance(transformer, torch.nn.Module):
+ raise TypeError(
+ "MiniMax H3 Ref2VA FSDP2 LoRA chunking expected an nn.Module base, "
+ f"received {type(transformer).__name__}"
+ )
+ configured = install_h3_lora_projection_chunking(transformer)
+ logger.info(
+ "Enabled token-chunked PEFT projections for MiniMax H3 Ref2VA FSDP2: "
+ "configured=%d, max_tokens=%d",
+ configured,
+ H3_MAX_LORA_PROJECTION_TOKENS,
+ )
+ return result
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
index 08ef21558..98f6a942a 100644
--- a/tests/models/minimax_h3/test_chunking.py
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -16,15 +16,18 @@
import pytest
import torch
+from peft import LoraConfig, get_peft_model
from torch import nn
from torch.nn import functional as F
from torch.utils.checkpoint import checkpoint
from flow_factory.models.minimax_h3._chunking import (
_ChunkedFeedForward,
+ _ChunkedLoraLinear,
_ChunkedRMSNorm,
install_h3_attention_norm_chunking,
install_h3_feed_forward_chunking,
+ install_h3_lora_projection_chunking,
)
@@ -60,8 +63,12 @@ class BlockFake(nn.Module):
def __init__(self) -> None:
super().__init__()
self.attn = nn.Module()
+ self.attn.to_q = nn.Linear(5, 5, bias=False)
+ self.attn.to_k = nn.Linear(5, 5, bias=False)
+ self.attn.to_v = nn.Linear(5, 5, bias=False)
self.attn.norm_q = nn.RMSNorm(5)
self.attn.norm_k = nn.RMSNorm(5)
+ self.attn.to_out = nn.ModuleList([nn.Linear(5, 5, bias=False), nn.Dropout(0.0)])
self.ff = FeedForwardFake()
@@ -80,6 +87,23 @@ def _feed_forwards(transformer: TransformerFake) -> list[nn.Module]:
]
+def _lora_transformer(*, lora_dropout: float = 0.0) -> nn.Module:
+ model = get_peft_model(
+ TransformerFake(),
+ LoraConfig(
+ r=2,
+ lora_alpha=4,
+ target_modules=["to_q", "to_k", "to_v", "to_out.0"],
+ init_lora_weights="gaussian",
+ lora_dropout=lora_dropout,
+ ),
+ )
+ for module in model.modules():
+ if hasattr(module, "lora_B") and "default" in module.lora_B:
+ nn.init.normal_(module.lora_B["default"].weight)
+ return model
+
+
def test_chunking_preserves_parameter_tree_and_is_idempotent() -> None:
transformer = TransformerFake()
state = deepcopy(transformer.state_dict())
@@ -326,3 +350,123 @@ def test_attention_norm_chunking_rejects_conflicting_reinstallation() -> None:
with pytest.raises(ValueError, match="already uses max_tokens=4.*conflicting 8"):
install_h3_attention_norm_chunking(transformer, max_tokens=8)
+
+
+def test_lora_projection_chunking_preserves_peft_tree_and_is_idempotent() -> None:
+ model = _lora_transformer()
+ transformer = model.get_base_model()
+ state = deepcopy(model.state_dict())
+ keys_before = tuple(state)
+ parameter_ids_before = {name: id(parameter) for name, parameter in model.named_parameters()}
+
+ assert install_h3_lora_projection_chunking(transformer, max_tokens=4) == 20
+ assert install_h3_lora_projection_chunking(transformer, max_tokens=4) == 20
+
+ projections = [
+ projection
+ for block in (
+ *transformer.token_refiner.refiner_blocks,
+ *transformer.transformer_blocks,
+ )
+ for projection in (
+ block.attn.to_q,
+ block.attn.to_k,
+ block.attn.to_v,
+ block.attn.to_out[0],
+ )
+ ]
+ assert all(isinstance(projection, _ChunkedLoraLinear) for projection in projections)
+ assert tuple(model.state_dict()) == keys_before
+ assert {
+ name: id(parameter) for name, parameter in model.named_parameters()
+ } == parameter_ids_before
+ model.load_state_dict(state, strict=True)
+
+
+def test_lora_projection_chunking_preserves_remainder_forward_backward() -> None:
+ torch.manual_seed(29)
+ direct_model = _lora_transformer().double()
+ chunked_model = deepcopy(direct_model)
+ chunked_transformer = chunked_model.get_base_model()
+ install_h3_lora_projection_chunking(chunked_transformer, max_tokens=4)
+ direct = direct_model.get_base_model().transformer_blocks[0].attn.to_k
+ chunked = chunked_transformer.transformer_blocks[0].attn.to_k
+ chunk_sizes: list[int] = []
+ handle = chunked.base_layer.register_forward_pre_hook(
+ lambda _module, inputs: chunk_sizes.append(inputs[0].shape[1])
+ )
+ direct_input = torch.randn(2, 9, 5, dtype=torch.float64, requires_grad=True)
+ chunked_input = direct_input.detach().clone().requires_grad_(True)
+ output_gradient = torch.randn(2, 9, 5, dtype=torch.float64)
+
+ direct_output = direct(direct_input)
+ chunked_output = chunked(chunked_input)
+ direct_output.backward(output_gradient)
+ chunked_output.backward(output_gradient)
+ handle.remove()
+
+ assert chunk_sizes == [4, 4, 1]
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_input.grad, direct_input.grad)
+ for direct_parameter, chunked_parameter in zip(direct.parameters(), chunked.parameters()):
+ torch.testing.assert_close(chunked_parameter.grad, direct_parameter.grad)
+
+
+def test_lora_projection_chunking_preserves_mixed_batch_adapter_names() -> None:
+ direct_model = _lora_transformer()
+ chunked_model = deepcopy(direct_model)
+ install_h3_lora_projection_chunking(chunked_model.get_base_model(), max_tokens=4)
+ direct = direct_model.get_base_model().transformer_blocks[0].attn.to_v
+ chunked = chunked_model.get_base_model().transformer_blocks[0].attn.to_v
+ hidden_states = torch.randn(2, 9, 5)
+ adapter_names = ["default", "__base__"]
+
+ direct_output = direct(hidden_states, adapter_names=adapter_names)
+ chunked_output = chunked(hidden_states, adapter_names=adapter_names)
+
+ torch.testing.assert_close(chunked_output, direct_output)
+
+
+def test_lora_projection_chunking_rejects_conflicting_reinstallation() -> None:
+ transformer = _lora_transformer().get_base_model()
+ install_h3_lora_projection_chunking(transformer, max_tokens=4)
+
+ with pytest.raises(ValueError, match="already uses max_tokens=4.*conflicting 8"):
+ install_h3_lora_projection_chunking(transformer, max_tokens=8)
+
+
+def test_lora_projection_chunking_rejects_nonzero_dropout() -> None:
+ transformer = _lora_transformer(lora_dropout=0.1).get_base_model()
+
+ with pytest.raises(TypeError, match="requires zero dropout"):
+ install_h3_lora_projection_chunking(transformer, max_tokens=4)
+
+
+def test_lora_projection_chunking_rejects_existing_execution_hooks() -> None:
+ transformer = _lora_transformer().get_base_model()
+ transformer.transformer_blocks[0].attn.to_q.register_forward_pre_hook(
+ lambda _module, _inputs: None
+ )
+
+ with pytest.raises(TypeError, match="must be configured before execution hooks"):
+ install_h3_lora_projection_chunking(transformer, max_tokens=4)
+
+
+def test_lora_projection_chunking_preserves_non_reentrant_checkpoint_backward() -> None:
+ direct_model = _lora_transformer().double()
+ chunked_model = deepcopy(direct_model)
+ install_h3_lora_projection_chunking(chunked_model.get_base_model(), max_tokens=4)
+ direct = direct_model.get_base_model().transformer_blocks[0].attn.to_q
+ chunked = chunked_model.get_base_model().transformer_blocks[0].attn.to_q
+ direct_input = torch.randn(2, 9, 5, dtype=torch.float64, requires_grad=True)
+ chunked_input = direct_input.detach().clone().requires_grad_(True)
+
+ direct_output = direct(direct_input)
+ chunked_output = checkpoint(chunked, chunked_input, use_reentrant=False)
+ direct_output.sum().backward()
+ chunked_output.sum().backward()
+
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_input.grad, direct_input.grad)
+ for direct_parameter, chunked_parameter in zip(direct.parameters(), chunked.parameters()):
+ torch.testing.assert_close(chunked_parameter.grad, direct_parameter.grad)
From 182262a0d0ff88de8bdddbc0386955e9b5fae741 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 00:29:29 +0800
Subject: [PATCH 56/76] [models] fix: avoid H3 chunk assembly copies
---
.agents/knowledge/topics/fix_patterns.md | 14 +++
.../models/minimax_h3/_chunking.py | 90 +++++++++++--------
tests/models/minimax_h3/test_chunking.py | 19 ++++
3 files changed, 87 insertions(+), 36 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 0861496c9..c5ae0a635 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -609,6 +609,20 @@ Based on the fix type, write the fix entry to the appropriate document:
recreate the same peak through an avoidable second full-size result.
- **Related Constraint**: N/A
+### Token chunk aggregation must not duplicate the complete packed output
+- **Date**: 2026-08-31
+- **Symptom**: After H3 Ref2VA FSDP2 crossed the adapted projection peak, training exhausted a
+ 95 GiB device when Q RMSNorm's chunk aggregation requested a 190 MiB output with only
+ 154--174 MiB free.
+- **Root Cause**: The chunk executors retained a list whose outputs already totaled the complete
+ packed tensor, then `torch.cat` allocated a second complete tensor to assemble that list.
+- **Fix**: H3 feed-forward, Q/K RMSNorm, and PEFT projection chunk executors now allocate their final
+ output once and copy each non-overlapping token slice into it. CopySlices preserves input and
+ parameter gradients, including nested non-reentrant checkpoint replay, without a concatenation copy.
+- **Lesson**: Bounding each operator invocation is insufficient if aggregation recreates a full-size
+ peak. Treat chunk assembly as part of the memory contract and retain exactly one final output.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index dc65a853e..f169cef0c 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -16,7 +16,7 @@
from __future__ import annotations
-from typing import Iterable
+from typing import Callable, Iterable
import torch
from peft.tuners.lora.layer import Linear as LoraLinear
@@ -55,23 +55,23 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
)
if hidden_states.shape[1] <= self.max_tokens:
return self._forward_chunk(hidden_states)
- chunks = hidden_states.split(self.max_tokens, dim=1)
if torch.is_grad_enabled():
- return torch.cat(
- [
- checkpoint(
- self._forward_chunk,
- chunk,
- use_reentrant=False,
- preserve_rng_state=True,
- )
- for chunk in chunks
- ],
- dim=1,
+ return _assemble_sequence_chunks(
+ hidden_states,
+ self.max_tokens,
+ lambda chunk: checkpoint(
+ self._forward_chunk,
+ chunk,
+ use_reentrant=False,
+ preserve_rng_state=True,
+ ),
+ operation="feed-forward",
)
- return torch.cat(
- [self._forward_chunk(chunk) for chunk in chunks],
- dim=1,
+ return _assemble_sequence_chunks(
+ hidden_states,
+ self.max_tokens,
+ self._forward_chunk,
+ operation="feed-forward",
)
@@ -103,9 +103,11 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
)
if hidden_states.shape[1] <= self.max_tokens:
return self._forward_chunk(hidden_states)
- return torch.cat(
- [self._forward_chunk(chunk) for chunk in hidden_states.split(self.max_tokens, dim=1)],
- dim=1,
+ return _assemble_sequence_chunks(
+ hidden_states,
+ self.max_tokens,
+ self._forward_chunk,
+ operation="RMSNorm",
)
@@ -123,26 +125,42 @@ def forward(self, hidden_states: torch.Tensor, *args, **kwargs) -> torch.Tensor:
return super().forward(hidden_states, *args, **kwargs)
forward_chunk = super().forward
- output = None
- offset = 0
- for chunk in hidden_states.split(max_tokens, dim=1):
- chunk_output = forward_chunk(chunk, *args, **kwargs)
- if chunk_output.shape[:-1] != chunk.shape[:-1]:
- raise RuntimeError(
- "MiniMax H3 LoRA projection must preserve input prefix dimensions, "
- f"received input={tuple(chunk.shape)}, output={tuple(chunk_output.shape)}"
- )
- if output is None:
- output = chunk_output.new_empty((*hidden_states.shape[:-1], chunk_output.shape[-1]))
- output.narrow(1, offset, chunk_output.shape[1]).copy_(chunk_output)
- offset += chunk_output.shape[1]
+ return _assemble_sequence_chunks(
+ hidden_states,
+ max_tokens,
+ lambda chunk: forward_chunk(chunk, *args, **kwargs),
+ operation="LoRA projection",
+ )
+
- if output is None or offset != hidden_states.shape[1]:
+def _assemble_sequence_chunks(
+ hidden_states: torch.Tensor,
+ max_tokens: int,
+ forward_chunk: Callable[[torch.Tensor], torch.Tensor],
+ *,
+ operation: str,
+) -> torch.Tensor:
+ """Write token-local chunk results into one final allocation."""
+ output = None
+ offset = 0
+ for chunk in hidden_states.split(max_tokens, dim=1):
+ chunk_output = forward_chunk(chunk)
+ if chunk_output.shape[:-1] != chunk.shape[:-1]:
raise RuntimeError(
- "MiniMax H3 LoRA projection failed to assemble every input token, "
- f"received input_tokens={hidden_states.shape[1]}, output_tokens={offset}"
+ f"MiniMax H3 {operation} must preserve input prefix dimensions, "
+ f"received input={tuple(chunk.shape)}, output={tuple(chunk_output.shape)}"
)
- return output
+ if output is None:
+ output = chunk_output.new_empty((*hidden_states.shape[:-1], chunk_output.shape[-1]))
+ output.narrow(1, offset, chunk_output.shape[1]).copy_(chunk_output)
+ offset += chunk_output.shape[1]
+
+ if output is None or offset != hidden_states.shape[1]:
+ raise RuntimeError(
+ f"MiniMax H3 {operation} failed to assemble every input token, "
+ f"received input_tokens={hidden_states.shape[1]}, output_tokens={offset}"
+ )
+ return output
def install_h3_feed_forward_chunking(
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
index 98f6a942a..740ef1766 100644
--- a/tests/models/minimax_h3/test_chunking.py
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -352,6 +352,25 @@ def test_attention_norm_chunking_rejects_conflicting_reinstallation() -> None:
install_h3_attention_norm_chunking(transformer, max_tokens=8)
+def test_chunked_token_local_operations_avoid_full_output_concatenation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ transformer = TransformerFake()
+ install_h3_feed_forward_chunking(transformer, max_tokens=4)
+ install_h3_attention_norm_chunking(transformer, max_tokens=4)
+
+ monkeypatch.setattr(
+ torch,
+ "cat",
+ lambda *args, **kwargs: pytest.fail("chunk aggregation must not call torch.cat"),
+ )
+
+ hidden_states = torch.randn(2, 9, 5, requires_grad=True)
+ feed_forward = transformer.transformer_blocks[0].ff(hidden_states)
+ normalized = transformer.transformer_blocks[0].attn.norm_q(hidden_states)
+ (feed_forward + normalized).sum().backward()
+
+
def test_lora_projection_chunking_preserves_peft_tree_and_is_idempotent() -> None:
model = _lora_transformer()
transformer = model.get_base_model()
From 46a3c163b87bb5bfc4f6b1355e178e5608edfced Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 00:44:13 +0800
Subject: [PATCH 57/76] [models] fix: bound H3 rotary embedding memory
---
.agents/knowledge/topics/fix_patterns.md | 14 ++
.../models/minimax_h3/_chunking.py | 169 ++++++++++++++++++
.../models/minimax_h3/adapters.py | 16 +-
.../models/minimax_h3/dependency.py | 16 +-
tests/models/minimax_h3/test_chunking.py | 127 +++++++++++++
tests/models/minimax_h3/test_modular_core.py | 23 +++
6 files changed, 362 insertions(+), 3 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index c5ae0a635..6ebcce643 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -623,6 +623,20 @@ Based on the fix type, write the fix entry to the appropriate document:
peak. Treat chunk assembly as part of the memory contract and retain exactly one final output.
- **Related Constraint**: N/A
+### Rotary embedding should rotate packed rows in bounded slices
+- **Date**: 2026-08-31
+- **Symptom**: MiniMax H3 Ref2VA FSDP2 GRPO crossed projection, normalization, and aggregation
+ peaks but exhausted a 95 GiB device when rotary embedding requested a 144 MiB elementwise
+ product with only 74--134 MiB free.
+- **Root Cause**: Diffusers built rotate-half, cosine-product, sine-product, sum, and concatenation
+ intermediates over all 13,889 query or key rows simultaneously.
+- **Fix**: Ref2VA FSDP2 installs an instance-local H3 attention processor before distributed
+ preparation. It preserves projection, backend, and output behavior while applying Q/K rotary
+ embedding in aligned 1,024-token slices directly into one final output allocation.
+- **Lesson**: Positional rotation is row-local even when the following attention is global. Bound
+ its elementwise intermediates independently and keep cosine/sine slices aligned with token rows.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index f169cef0c..3a6ad366a 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -23,9 +23,12 @@
from torch import nn
from torch.utils.checkpoint import checkpoint
+from .dependency import require_minimax_h3_support
+
H3_MAX_FEED_FORWARD_TOKENS = 1024
H3_MAX_ATTENTION_NORM_TOKENS = 1024
H3_MAX_LORA_PROJECTION_TOKENS = 1024
+H3_MAX_ROTARY_TOKENS = 1024
class _ChunkedFeedForward(nn.Module):
@@ -133,6 +136,126 @@ def forward(self, hidden_states: torch.Tensor, *args, **kwargs) -> torch.Tensor:
)
+class _ChunkedH3AttnProcessor:
+ """Preserve the upstream H3 attention contract with bounded rotary work."""
+
+ _attention_backend = None
+ _parallel_config = None
+ flow_factory_max_tokens: int
+ flow_factory_dispatch_attention_fn: Callable[..., torch.Tensor]
+
+ def __call__(
+ self,
+ attn: nn.Module,
+ hidden_states: torch.Tensor,
+ rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None,
+ attention_mask: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ if attn.fused_projections:
+ query, key, value = attn.to_qkv(hidden_states).chunk(3, dim=-1)
+ else:
+ query = attn.to_q(hidden_states)
+ key = attn.to_k(hidden_states)
+ value = attn.to_v(hidden_states)
+
+ query = query.unflatten(-1, (attn.heads, -1))
+ key = key.unflatten(-1, (attn.heads, -1))
+ value = value.unflatten(-1, (attn.heads, -1))
+
+ query = attn.norm_q(query)
+ key = attn.norm_k(key)
+
+ if rotary_emb is not None:
+ query = _apply_h3_rotary_chunks(
+ query,
+ *rotary_emb,
+ max_tokens=self.flow_factory_max_tokens,
+ )
+ key = _apply_h3_rotary_chunks(
+ key,
+ *rotary_emb,
+ max_tokens=self.flow_factory_max_tokens,
+ )
+
+ hidden_states = self.flow_factory_dispatch_attention_fn(
+ query,
+ key,
+ value,
+ attn_mask=attention_mask,
+ dropout_p=0.0,
+ is_causal=False,
+ backend=self._attention_backend,
+ parallel_config=self._parallel_config,
+ )
+ hidden_states = hidden_states.flatten(2, 3).type_as(query)
+ hidden_states = attn.to_out[0](hidden_states)
+ hidden_states = attn.to_out[1](hidden_states)
+ return hidden_states
+
+
+def _apply_h3_rotary_chunks(
+ hidden_states: torch.Tensor,
+ cos: torch.Tensor,
+ sin: torch.Tensor,
+ *,
+ max_tokens: int,
+) -> torch.Tensor:
+ """Apply row-local H3 rotary embedding without full-sequence temporaries."""
+ max_tokens = _positive_int(max_tokens, "max_tokens")
+ if hidden_states.ndim != 4:
+ raise ValueError(
+ "MiniMax H3 rotary embedding expected [batch, tokens, heads, head_dim], "
+ f"received shape={tuple(hidden_states.shape)}"
+ )
+ if cos.ndim != 2 or sin.shape != cos.shape:
+ raise ValueError(
+ "MiniMax H3 rotary embedding expected matching [tokens, rotary_dim] cos/sin, "
+ f"received cos={tuple(cos.shape)}, sin={tuple(sin.shape)}"
+ )
+ if cos.shape[0] != hidden_states.shape[1]:
+ raise ValueError(
+ "MiniMax H3 rotary embedding sequence mismatch, received "
+ f"hidden_tokens={hidden_states.shape[1]}, rotary_tokens={cos.shape[0]}"
+ )
+ rotary_dim = cos.shape[-1]
+ if rotary_dim < 2 or rotary_dim % 2 or rotary_dim > hidden_states.shape[-1]:
+ raise ValueError(
+ "MiniMax H3 rotary_dim must be positive, even, and no larger than head_dim, "
+ f"received rotary_dim={rotary_dim}, head_dim={hidden_states.shape[-1]}"
+ )
+
+ cos = cos.to(hidden_states.dtype)
+ sin = sin.to(hidden_states.dtype)
+ if hidden_states.shape[1] <= max_tokens:
+ return _apply_h3_rotary_chunk(hidden_states, cos, sin)
+
+ cos_chunks = iter(cos.split(max_tokens, dim=0))
+ sin_chunks = iter(sin.split(max_tokens, dim=0))
+ return _assemble_sequence_chunks(
+ hidden_states,
+ max_tokens,
+ lambda chunk: _apply_h3_rotary_chunk(chunk, next(cos_chunks), next(sin_chunks)),
+ operation="rotary embedding",
+ )
+
+
+def _apply_h3_rotary_chunk(
+ hidden_states: torch.Tensor,
+ cos: torch.Tensor,
+ sin: torch.Tensor,
+) -> torch.Tensor:
+ """Apply the upstream rotate-half convention to one aligned token chunk."""
+ rotary_dim = cos.shape[-1]
+ hidden_states_rotary = hidden_states[..., :rotary_dim]
+ hidden_states_pass = hidden_states[..., rotary_dim:]
+ cos = cos[None, :, None, :]
+ sin = sin[None, :, None, :]
+ first, second = hidden_states_rotary.chunk(2, dim=-1)
+ hidden_states_rotated = torch.cat((-second, first), dim=-1)
+ hidden_states_rotary = hidden_states_rotary * cos + hidden_states_rotated * sin
+ return torch.cat((hidden_states_rotary, hidden_states_pass), dim=-1).contiguous()
+
+
def _assemble_sequence_chunks(
hidden_states: torch.Tensor,
max_tokens: int,
@@ -365,6 +488,52 @@ def install_h3_lora_projection_chunking(
return configured
+def install_h3_rotary_chunking(
+ transformer: nn.Module,
+ *,
+ max_tokens: int = H3_MAX_ROTARY_TOKENS,
+) -> int:
+ """Install the bounded processor on every standard H3 attention instance."""
+ max_tokens = _positive_int(max_tokens, "max_tokens")
+ symbols = require_minimax_h3_support()
+ processor_class = symbols.MiniMaxH3AttnProcessor
+ configured = 0
+ for block_name, block in _h3_repeated_blocks(transformer):
+ attention = getattr(block, "attn", None)
+ if not isinstance(attention, nn.Module):
+ raise TypeError(
+ f"MiniMax H3 {block_name}.attn expected nn.Module, received "
+ f"{type(attention).__name__}"
+ )
+ processor = getattr(attention, "processor", None)
+ if isinstance(processor, _ChunkedH3AttnProcessor):
+ if processor.flow_factory_max_tokens != max_tokens:
+ raise ValueError(
+ f"MiniMax H3 {block_name}.attn.processor already uses max_tokens="
+ f"{processor.flow_factory_max_tokens}, received conflicting {max_tokens}"
+ )
+ configured += 1
+ continue
+ if type(processor) is not processor_class:
+ raise TypeError(
+ f"MiniMax H3 {block_name}.attn expected the standard attention processor, "
+ f"received {type(processor).__name__}"
+ )
+ if "__call__" in processor.__dict__:
+ raise TypeError(
+ f"MiniMax H3 {block_name}.attn processor must not shadow __call__ on the instance"
+ )
+ attention_backend = getattr(processor, "_attention_backend", None)
+ parallel_config = getattr(processor, "_parallel_config", None)
+ processor.__class__ = _ChunkedH3AttnProcessor
+ processor.flow_factory_max_tokens = max_tokens
+ processor.flow_factory_dispatch_attention_fn = symbols.dispatch_attention_fn
+ processor._attention_backend = attention_backend
+ processor._parallel_config = parallel_config
+ configured += 1
+ return configured
+
+
def _h3_repeated_blocks(transformer: nn.Module) -> Iterable[tuple[str, nn.Module]]:
token_refiner = getattr(transformer, "token_refiner", None)
stacks = (
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index f84c386b4..411077c05 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -49,7 +49,9 @@
from ..runtime import ModularPipelineRuntime
from ._chunking import (
H3_MAX_LORA_PROJECTION_TOKENS,
+ H3_MAX_ROTARY_TOKENS,
install_h3_lora_projection_chunking,
+ install_h3_rotary_chunking,
)
from ._common import apply_forward_process_noise, draw_forward_process_noise
from ._condition import MiniMaxH3ConditionStatePreparer
@@ -138,7 +140,19 @@ def load_pipeline(self) -> Any:
def build_component_runtime(self) -> ModularPipelineRuntime:
"""Build the workflow-pruned modular runtime."""
- return build_h3_component_runtime(self)
+ runtime = build_h3_component_runtime(self)
+ if self.workflow != "ref2va" or not self._is_fsdp2():
+ return runtime
+ configured = install_h3_rotary_chunking(
+ runtime.get_component(self.transformer_component_name)
+ )
+ logger.info(
+ "Enabled token-chunked rotary embedding for MiniMax H3 Ref2VA FSDP2: "
+ "configured=%d, max_tokens=%d",
+ configured,
+ H3_MAX_ROTARY_TOKENS,
+ )
+ return runtime
def load_scheduler(self) -> MiniMaxH3SDEScheduler:
"""Build the canonical shift-12 video scheduler."""
diff --git a/src/flow_factory/models/minimax_h3/dependency.py b/src/flow_factory/models/minimax_h3/dependency.py
index 2a0f93258..936685ee3 100644
--- a/src/flow_factory/models/minimax_h3/dependency.py
+++ b/src/flow_factory/models/minimax_h3/dependency.py
@@ -15,7 +15,7 @@
import inspect
from dataclasses import dataclass
-from typing import Any, Tuple, Type
+from typing import Any, Callable, Tuple, Type
import torch
@@ -60,6 +60,8 @@ class MiniMaxH3Symbols:
ModularPipeline: Type[Any]
MiniMaxH3ModularPipeline: Type[Any]
MiniMaxH3Blocks: Type[Any]
+ MiniMaxH3AttnProcessor: Type[Any]
+ dispatch_attention_fn: Callable[..., torch.Tensor]
PipelineState: Type[Any]
ResizeStep: Type[Any]
RefSetupStep: Type[Any]
@@ -85,7 +87,8 @@ class MiniMaxH3Symbols:
try:
- from diffusers import ModularPipeline
+ from diffusers.models.attention_dispatch import dispatch_attention_fn
+ from diffusers.models.transformers.transformer_minimax_h3 import MiniMaxH3AttnProcessor
from diffusers.modular_pipelines.minimax_h3.before_denoise import (
MiniMaxH3FL2VAPrepareLatentsStep,
MiniMaxH3NoKeyframeAnchorsStep,
@@ -121,10 +124,14 @@ class MiniMaxH3Symbols:
)
from diffusers.modular_pipelines.modular_pipeline import PipelineState
+ from diffusers import ModularPipeline
+
_SYMBOLS = MiniMaxH3Symbols(
ModularPipeline=ModularPipeline,
MiniMaxH3ModularPipeline=MiniMaxH3ModularPipeline,
MiniMaxH3Blocks=MiniMaxH3Blocks,
+ MiniMaxH3AttnProcessor=MiniMaxH3AttnProcessor,
+ dispatch_attention_fn=dispatch_attention_fn,
PipelineState=PipelineState,
ResizeStep=MiniMaxH3ResizeStep,
RefSetupStep=MiniMaxH3Ref2VASetupStep,
@@ -172,6 +179,11 @@ def require_minimax_h3_support() -> MiniMaxH3Symbols:
def _probe_symbol_bundle(symbols: MiniMaxH3Symbols) -> None:
+ processor = symbols.MiniMaxH3AttnProcessor()
+ if not callable(processor):
+ raise TypeError("MiniMaxH3AttnProcessor instance must be callable")
+ if not callable(symbols.dispatch_attention_fn):
+ raise TypeError("dispatch_attention_fn must be callable")
state_values = {"probe": object()}
try:
state = symbols.PipelineState(values=state_values)
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
index 740ef1766..447e8d0ac 100644
--- a/tests/models/minimax_h3/test_chunking.py
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -16,18 +16,26 @@
import pytest
import torch
+from diffusers.models.transformers.transformer_minimax_h3 import (
+ MiniMaxH3AttnProcessor,
+ _apply_rotary_emb,
+)
from peft import LoraConfig, get_peft_model
from torch import nn
from torch.nn import functional as F
from torch.utils.checkpoint import checkpoint
from flow_factory.models.minimax_h3._chunking import (
+ _apply_h3_rotary_chunk,
+ _apply_h3_rotary_chunks,
_ChunkedFeedForward,
+ _ChunkedH3AttnProcessor,
_ChunkedLoraLinear,
_ChunkedRMSNorm,
install_h3_attention_norm_chunking,
install_h3_feed_forward_chunking,
install_h3_lora_projection_chunking,
+ install_h3_rotary_chunking,
)
@@ -63,12 +71,15 @@ class BlockFake(nn.Module):
def __init__(self) -> None:
super().__init__()
self.attn = nn.Module()
+ self.attn.heads = 1
+ self.attn.fused_projections = False
self.attn.to_q = nn.Linear(5, 5, bias=False)
self.attn.to_k = nn.Linear(5, 5, bias=False)
self.attn.to_v = nn.Linear(5, 5, bias=False)
self.attn.norm_q = nn.RMSNorm(5)
self.attn.norm_k = nn.RMSNorm(5)
self.attn.to_out = nn.ModuleList([nn.Linear(5, 5, bias=False), nn.Dropout(0.0)])
+ self.attn.processor = MiniMaxH3AttnProcessor()
self.ff = FeedForwardFake()
@@ -489,3 +500,119 @@ def test_lora_projection_chunking_preserves_non_reentrant_checkpoint_backward()
torch.testing.assert_close(chunked_input.grad, direct_input.grad)
for direct_parameter, chunked_parameter in zip(direct.parameters(), chunked.parameters()):
torch.testing.assert_close(chunked_parameter.grad, direct_parameter.grad)
+
+
+def test_rotary_chunking_preserves_remainder_forward_backward(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ torch.manual_seed(31)
+ direct_input = torch.randn(2, 9, 3, 8, dtype=torch.float64, requires_grad=True)
+ chunked_input = direct_input.detach().clone().requires_grad_(True)
+ direct_cos = torch.randn(9, 6, dtype=torch.float64, requires_grad=True)
+ chunked_cos = direct_cos.detach().clone().requires_grad_(True)
+ direct_sin = torch.randn(9, 6, dtype=torch.float64, requires_grad=True)
+ chunked_sin = direct_sin.detach().clone().requires_grad_(True)
+ output_gradient = torch.randn(2, 9, 3, 8, dtype=torch.float64)
+ chunk_sizes: list[int] = []
+
+ def recording_rotary_chunk(hidden_states, chunk_cos, chunk_sin):
+ chunk_sizes.append(hidden_states.shape[1])
+ return _apply_h3_rotary_chunk(hidden_states, chunk_cos, chunk_sin)
+
+ monkeypatch.setattr(
+ "flow_factory.models.minimax_h3._chunking._apply_h3_rotary_chunk",
+ recording_rotary_chunk,
+ )
+
+ direct_output = _apply_rotary_emb(direct_input, direct_cos, direct_sin)
+ chunked_output = _apply_h3_rotary_chunks(
+ chunked_input,
+ chunked_cos,
+ chunked_sin,
+ max_tokens=4,
+ )
+ direct_output.backward(output_gradient)
+ chunked_output.backward(output_gradient)
+
+ assert chunk_sizes == [4, 4, 1]
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_input.grad, direct_input.grad)
+ torch.testing.assert_close(chunked_cos.grad, direct_cos.grad)
+ torch.testing.assert_close(chunked_sin.grad, direct_sin.grad)
+
+
+def test_rotary_chunking_preserves_processor_and_parameter_tree() -> None:
+ transformer = TransformerFake()
+ attention_backend = object()
+ parallel_config = object()
+ first_processor = transformer.token_refiner.refiner_blocks[0].attn.processor
+ first_processor._attention_backend = attention_backend
+ first_processor._parallel_config = parallel_config
+ state = deepcopy(transformer.state_dict())
+ keys_before = tuple(state)
+ parameter_ids_before = {
+ name: id(parameter) for name, parameter in transformer.named_parameters()
+ }
+ processor_ids_before = [
+ id(block.attn.processor)
+ for block in (
+ *transformer.token_refiner.refiner_blocks,
+ *transformer.transformer_blocks,
+ )
+ ]
+
+ assert install_h3_rotary_chunking(transformer, max_tokens=4) == 5
+ assert install_h3_rotary_chunking(transformer, max_tokens=4) == 5
+
+ processors = [
+ block.attn.processor
+ for block in (
+ *transformer.token_refiner.refiner_blocks,
+ *transformer.transformer_blocks,
+ )
+ ]
+ assert all(isinstance(processor, _ChunkedH3AttnProcessor) for processor in processors)
+ assert [id(processor) for processor in processors] == processor_ids_before
+ assert processors[0]._attention_backend is attention_backend
+ assert processors[0]._parallel_config is parallel_config
+ assert tuple(transformer.state_dict()) == keys_before
+ assert {
+ name: id(parameter) for name, parameter in transformer.named_parameters()
+ } == parameter_ids_before
+ transformer.load_state_dict(state, strict=True)
+
+
+def test_rotary_chunking_processor_preserves_attention_forward_backward() -> None:
+ torch.manual_seed(37)
+ direct_transformer = TransformerFake().double()
+ chunked_transformer = deepcopy(direct_transformer)
+ install_h3_rotary_chunking(chunked_transformer, max_tokens=4)
+ direct = direct_transformer.transformer_blocks[0].attn
+ chunked = chunked_transformer.transformer_blocks[0].attn
+ direct_input = torch.randn(2, 9, 5, dtype=torch.float64, requires_grad=True)
+ chunked_input = direct_input.detach().clone().requires_grad_(True)
+ cos = torch.randn(9, 4, dtype=torch.float64)
+ sin = torch.randn(9, 4, dtype=torch.float64)
+ output_gradient = torch.randn(2, 9, 5, dtype=torch.float64)
+
+ direct_output = direct.processor(direct, direct_input, (cos, sin))
+ chunked_output = checkpoint(
+ lambda value: chunked.processor(chunked, value, (cos, sin)),
+ chunked_input,
+ use_reentrant=False,
+ )
+ direct_output.backward(output_gradient)
+ chunked_output.backward(output_gradient)
+
+ torch.testing.assert_close(chunked_output, direct_output)
+ torch.testing.assert_close(chunked_input.grad, direct_input.grad)
+ for direct_parameter, chunked_parameter in zip(direct.parameters(), chunked.parameters()):
+ torch.testing.assert_close(chunked_parameter.grad, direct_parameter.grad)
+
+
+def test_rotary_chunking_rejects_conflicting_reinstallation() -> None:
+ transformer = TransformerFake()
+ install_h3_rotary_chunking(transformer, max_tokens=4)
+
+ with pytest.raises(ValueError, match="already uses max_tokens=4.*conflicting 8"):
+ install_h3_rotary_chunking(transformer, max_tokens=8)
diff --git a/tests/models/minimax_h3/test_modular_core.py b/tests/models/minimax_h3/test_modular_core.py
index 160b02397..5146fa77c 100644
--- a/tests/models/minimax_h3/test_modular_core.py
+++ b/tests/models/minimax_h3/test_modular_core.py
@@ -202,6 +202,15 @@ class FakeMiniMaxH3Blocks:
}
+class FakeMiniMaxH3AttnProcessor:
+ def __call__(self, *args, **kwargs):
+ return None
+
+
+def fake_dispatch_attention_fn(*args, **kwargs):
+ return None
+
+
class FakeReference:
pass
@@ -210,6 +219,8 @@ class FakeReference:
class FakeSymbols:
MiniMaxH3ModularPipeline: type = FakeModularPipeline
MiniMaxH3Blocks: type = FakeMiniMaxH3Blocks
+ MiniMaxH3AttnProcessor: type = FakeMiniMaxH3AttnProcessor
+ dispatch_attention_fn: object = fake_dispatch_attention_fn
PipelineState: type = FakePipelineState
ResizeStep: type = ResizeStep
RefSetupStep: type = RefSetupStep
@@ -1052,6 +1063,7 @@ def __call__(self):
[
"PipelineState",
"MiniMaxH3Blocks",
+ "MiniMaxH3AttnProcessor",
"PrepareLatentsStep",
"Ref2VATextEncoderStep",
"SetTimestepsStep",
@@ -1075,6 +1087,17 @@ def test_dependency_probe_rejects_incompatible_api_with_actionable_requirement(
assert broken_field in message
+def test_dependency_probe_rejects_non_callable_attention_dispatch(monkeypatch):
+ from flow_factory.models.minimax_h3 import dependency
+
+ bundle = dataclasses.replace(FakeSymbols(), dispatch_attention_fn=object())
+ monkeypatch.setattr(dependency, "_SYMBOLS", bundle)
+ monkeypatch.setattr(dependency, "_IMPORT_ERROR", None)
+
+ with pytest.raises(ImportError, match="dispatch_attention_fn must be callable"):
+ dependency.require_minimax_h3_support()
+
+
def test_pyproject_requires_released_h3_diffusers():
text = Path("pyproject.toml").read_text()
requirement = "diffusers>=0.40.0"
From 71526e06301a8197b28c41eb8947e1b72756fc19 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 00:48:59 +0800
Subject: [PATCH 58/76] [models] fix: release H3 attention inputs promptly
---
.agents/knowledge/topics/fix_patterns.md | 14 +++++++++++
.../models/minimax_h3/_chunking.py | 4 +++-
tests/models/minimax_h3/test_chunking.py | 23 +++++++++++++++++++
3 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 6ebcce643..c87a7b68d 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -637,6 +637,20 @@ Based on the fix type, write the fix entry to the appropriate document:
its elementwise intermediates independently and keep cosine/sine slices aligned with token rows.
- **Related Constraint**: N/A
+### Checkpointed attention should release QKV before its output projection
+- **Date**: 2026-08-31
+- **Symptom**: After bounded rotary embedding passed, H3 Ref2VA FSDP2 GRPO missed a 144 MiB
+ output-projection allocation by roughly 10 MiB while the attention result was already available.
+- **Root Cause**: The attention processor kept strong Python references to complete Q/K/V tensors
+ through the output projection. FSDP2 activation checkpointing had discarded their saved-tensor
+ storage requirements, but the local variables still extended their lifetime.
+- **Fix**: The bounded H3 processor captures the output dtype, releases Q/K/V immediately after
+ attention dispatch, and only then flattens and projects the attention result.
+- **Lesson**: Under activation checkpointing, autograd may no longer own an intermediate while a
+ Python local still does. End large tensor lifetimes at their last semantic use before allocating
+ the next full-size result.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index 3a6ad366a..e55920816 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -177,6 +177,7 @@ def __call__(
max_tokens=self.flow_factory_max_tokens,
)
+ output_dtype = query.dtype
hidden_states = self.flow_factory_dispatch_attention_fn(
query,
key,
@@ -187,7 +188,8 @@ def __call__(
backend=self._attention_backend,
parallel_config=self._parallel_config,
)
- hidden_states = hidden_states.flatten(2, 3).type_as(query)
+ del query, key, value
+ hidden_states = hidden_states.flatten(2, 3).to(dtype=output_dtype)
hidden_states = attn.to_out[0](hidden_states)
hidden_states = attn.to_out[1](hidden_states)
return hidden_states
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
index 447e8d0ac..f17f5c3db 100644
--- a/tests/models/minimax_h3/test_chunking.py
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -13,6 +13,7 @@
# limitations under the License.
from copy import deepcopy
+from weakref import ref
import pytest
import torch
@@ -616,3 +617,25 @@ def test_rotary_chunking_rejects_conflicting_reinstallation() -> None:
with pytest.raises(ValueError, match="already uses max_tokens=4.*conflicting 8"):
install_h3_rotary_chunking(transformer, max_tokens=8)
+
+
+def test_chunked_attention_releases_qkv_before_output_projection() -> None:
+ transformer = TransformerFake()
+ install_h3_rotary_chunking(transformer, max_tokens=4)
+ attention = transformer.transformer_blocks[0].attn
+ qkv_refs = []
+
+ def recording_dispatch(query, key, value, **kwargs):
+ del kwargs
+ qkv_refs.extend((ref(query), ref(key), ref(value)))
+ return torch.zeros_like(query)
+
+ attention.processor.flow_factory_dispatch_attention_fn = recording_dispatch
+
+ def require_released_qkv(_module, _inputs):
+ assert len(qkv_refs) == 3
+ assert all(tensor_ref() is None for tensor_ref in qkv_refs)
+
+ handle = attention.to_out[0].register_forward_pre_hook(require_released_qkv)
+ attention.processor(attention, torch.randn(2, 9, 5))
+ handle.remove()
From 84229f6ea495f0fe8f0772436fa29a634b481d36 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 01:19:09 +0800
Subject: [PATCH 59/76] [loading,models] fix: bound H3 FSDP2 checkpoint memory
---
.agents/knowledge/topics/fix_patterns.md | 21 ++++
src/flow_factory/loading/backend.py | 103 +++++++++++++++---
src/flow_factory/models/abc.py | 4 +
.../models/minimax_h3/_chunking.py | 68 ++++++++++++
.../models/minimax_h3/adapters.py | 23 ++++
tests/loading/test_backend_runtime.py | 55 ++++++++++
tests/models/minimax_h3/test_chunking.py | 75 +++++++++++++
.../minimax_h3/test_workflow_adapters.py | 6 +
8 files changed, 340 insertions(+), 15 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index c87a7b68d..ef927458a 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -651,6 +651,27 @@ Based on the fix type, write the fix entry to the appropriate document:
the next full-size result.
- **Related Constraint**: N/A
+### FSDP2 checkpoint and backward-prefetch policies must have independent memory boundaries
+- **Date**: 2026-08-31
+- **Symptom**: MiniMax H3 Ref2VA FSDP2 GRPO reached the training forward only after several
+ operator-level bounds, then exhausted each 95 GiB device during forward activation retention or
+ the first backward all-gather. The final backward failure requested 286 MiB while the 442 MiB
+ feed-forward unit was still resident.
+- **Root Cause**: Accelerate reused the FSDP2 transformer wrap policy for activation checkpointing
+ and checkpointed every direct child of each H3 block, retaining several complete packed-sequence
+ inputs per block. After replacing those boundaries, PyTorch's implicit backward prefetch still
+ overlapped the current feed-forward unit with the next attention unit's all-gather.
+- **Fix**: Ref2VA now installs one non-reentrant checkpoint inside every materialized H3 block after
+ the block's FSDP mixed-precision input cast, with its saved BF16 inputs held in pinned CPU memory.
+ Backend preparation disables Accelerate's duplicate checkpoint owner and opts every prepared
+ FSDP2 unit out of implicit next-unit backward prefetch, so the current unit reshards before its
+ successor gathers. All variant instances are configured only after materialization. A two-rank
+ GRPO sentinel completed two forward/backward/optimizer cycles with stable gradients.
+- **Lesson**: FSDP wrap granularity, activation recomputation, saved-input placement, and collective
+ prefetch are separate memory policies. Keep each boundary explicit; a policy that is correct for
+ parameter sharding may multiply activation lifetimes or overlap adjacent full-parameter units.
+- **Related Constraint**: #9, #20
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/loading/backend.py b/src/flow_factory/loading/backend.py
index 2f68133ca..56d3e3a71 100644
--- a/src/flow_factory/loading/backend.py
+++ b/src/flow_factory/loading/backend.py
@@ -122,30 +122,103 @@ def load_scope(self, role: ComponentRole) -> Iterator[None]:
def prepare(self, *objects: Any) -> Any:
"""Prepare roots and apply adapter-requested FSDP2 communication policy."""
- self._extend_fsdp2_wrap_policy(objects)
- prepared = super().prepare(*objects)
plugin = self.accelerator.state.fsdp_plugin
- if (getattr(plugin, "fsdp_version", 1) or 1) < 2 or not getattr(
- self.adapter, "fsdp2_use_default_stream_unshard", False
- ):
+ use_in_forward_checkpointing = (
+ (getattr(plugin, "fsdp_version", 1) or 1) >= 2
+ and bool(getattr(plugin, "activation_checkpointing", False))
+ and bool(
+ getattr(
+ self.adapter,
+ "fsdp2_use_in_forward_activation_checkpointing",
+ False,
+ )
+ )
+ )
+ if use_in_forward_checkpointing:
+ modules = [candidate for candidate in objects if isinstance(candidate, nn.Module)]
+ configure = getattr(
+ self.adapter,
+ "configure_fsdp2_in_forward_activation_checkpointing",
+ None,
+ )
+ if len(modules) != 1 or not callable(configure):
+ raise TypeError(
+ "adapter-owned FSDP2 activation checkpointing requires exactly one "
+ "model root and a callable configuration hook"
+ )
+ configured = configure(modules[0])
+ if not isinstance(configured, int) or isinstance(configured, bool) or configured < 1:
+ raise TypeError(
+ "adapter-owned FSDP2 activation checkpointing expected a positive "
+ f"configured block count, received {configured!r}"
+ )
+ plugin.activation_checkpointing = False
+ logger.info(
+ "Delegated FSDP2 activation checkpointing to adapter-owned in-forward "
+ "block boundaries: configured=%d",
+ configured,
+ )
+
+ try:
+ self._extend_fsdp2_wrap_policy(objects)
+ prepared = super().prepare(*objects)
+ finally:
+ if use_in_forward_checkpointing:
+ plugin.activation_checkpointing = True
+
+ if (getattr(plugin, "fsdp_version", 1) or 1) < 2:
+ return prepared
+
+ use_default_stream = bool(getattr(self.adapter, "fsdp2_use_default_stream_unshard", False))
+ disable_backward_prefetch = bool(
+ getattr(self.adapter, "fsdp2_disable_backward_prefetch", False)
+ )
+ if not use_default_stream and not disable_backward_prefetch:
return prepared
prepared_objects = prepared if isinstance(prepared, (list, tuple)) else (prepared,)
- configured = []
+ prepared_modules = []
for original, candidate in zip(objects, prepared_objects):
if not isinstance(original, nn.Module):
continue
- configure = getattr(candidate, "_set_unshard_async_op", None)
- if not callable(configure):
+ prepared_modules.append(candidate)
+
+ if not prepared_modules:
+ raise TypeError("FSDP2 memory policy requested without a prepared module")
+
+ if use_default_stream:
+ configured = []
+ for candidate in prepared_modules:
+ configure = getattr(candidate, "_set_unshard_async_op", None)
+ if not callable(configure):
+ raise TypeError(
+ "FSDP2 default-stream unshard requires prepared modules to expose "
+ f"_set_unshard_async_op(), received {type(candidate).__name__}"
+ )
+ configure(True)
+ configured.append(type(candidate).__name__)
+ logger.info("Enabled FSDP2 default-stream unshard for roots: %s", configured)
+
+ if disable_backward_prefetch:
+ configured = 0
+ for root in prepared_modules:
+ for module in root.modules():
+ configure = getattr(module, "set_modules_to_backward_prefetch", None)
+ if not callable(configure):
+ continue
+ # FSDP2 treats an empty list as "use default next-unit prefetch".
+ # Prefetching the current, already-unsharded unit is a public-API
+ # no-op that selects explicit mode and disables that default.
+ configure([module])
+ configured += 1
+ if configured < 1:
raise TypeError(
- "FSDP2 default-stream unshard requires prepared modules to expose "
- f"_set_unshard_async_op(), received {type(candidate).__name__}"
+ "FSDP2 backward-prefetch opt-out requires prepared FSDPModule roots"
)
- configure(True)
- configured.append(type(candidate).__name__)
- if not configured:
- raise TypeError("FSDP2 default-stream unshard requested without a prepared module")
- logger.info("Enabled FSDP2 default-stream unshard for roots: %s", configured)
+ logger.info(
+ "Disabled default FSDP2 next-unit backward prefetch: configured=%d",
+ configured,
+ )
return prepared
def _extend_fsdp2_wrap_policy(self, objects: Sequence[Any]) -> None:
diff --git a/src/flow_factory/models/abc.py b/src/flow_factory/models/abc.py
index 749ea103e..1b81b5536 100644
--- a/src/flow_factory/models/abc.py
+++ b/src/flow_factory/models/abc.py
@@ -236,6 +236,10 @@ class BaseAdapter(ABC):
# Opt in only when FSDP2 communication overlap exceeds the model's activation headroom.
fsdp2_use_default_stream_unshard: ClassVar[bool] = False
fsdp2_additional_wrap_module_names: ClassVar[Tuple[str, ...]] = ()
+ # The adapter may place one checkpoint inside each FSDP-wrapped block forward.
+ fsdp2_use_in_forward_activation_checkpointing: ClassVar[bool] = False
+ # Opt in when backward all-gather overlap exceeds the model's peak headroom.
+ fsdp2_disable_backward_prefetch: ClassVar[bool] = False
supports_ordered_references: ClassVar[bool] = False
preprocess_cache_fields: ClassVar[frozenset[str]] = frozenset()
preprocess_cache_version: ClassVar[str] = ""
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index e55920816..3f346bd5c 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -16,11 +16,13 @@
from __future__ import annotations
+from types import MethodType
from typing import Callable, Iterable
import torch
from peft.tuners.lora.layer import Linear as LoraLinear
from torch import nn
+from torch.autograd.graph import save_on_cpu
from torch.utils.checkpoint import checkpoint
from .dependency import require_minimax_h3_support
@@ -31,6 +33,29 @@
H3_MAX_ROTARY_TOKENS = 1024
+def _checkpoint_h3_block_forward(self: nn.Module, *args, **kwargs):
+ """Checkpoint one block after its FSDP cast and offload saved inputs to CPU."""
+ original_forward = self.__dict__["flow_factory_uncheckpointed_forward"]
+ if not torch.is_grad_enabled():
+ return original_forward(self, *args, **kwargs)
+ input_device_type = next(
+ (argument.device.type for argument in args if isinstance(argument, torch.Tensor)),
+ "cuda",
+ )
+ with save_on_cpu(
+ pin_memory=input_device_type == "cuda" and torch.cuda.is_available(),
+ device_type=input_device_type,
+ ):
+ return checkpoint(
+ original_forward,
+ self,
+ *args,
+ use_reentrant=False,
+ preserve_rng_state=False,
+ **kwargs,
+ )
+
+
class _ChunkedFeedForward(nn.Module):
"""Run one existing feed-forward network over remainder-safe token chunks.
@@ -350,6 +375,49 @@ def install_h3_feed_forward_chunking(
return configured
+def install_h3_in_forward_block_checkpointing(transformer: nn.Module) -> int:
+ """Place one checkpoint inside every repeated block's FSDP execution boundary.
+
+ Diffusers model-level checkpointing surrounds the block call and therefore saves
+ inputs before FSDP2 casts them for mixed-precision compute. Accelerate's generic
+ FSDP2 policy checkpoints each direct child separately. This instance-local
+ forward delegates the complete original block body to one non-reentrant
+ checkpoint after the block's FSDP pre-forward hook has already run. Its saved
+ BF16 block inputs live in pinned CPU memory until their backward replay.
+ """
+ configured = 0
+ for block_name, block in _h3_repeated_blocks(transformer):
+ installed = block.__dict__.get("flow_factory_uncheckpointed_forward")
+ shadowed_forward = block.__dict__.get("forward")
+ if installed is not None:
+ if not (
+ callable(installed)
+ and isinstance(shadowed_forward, MethodType)
+ and shadowed_forward.__func__ is _checkpoint_h3_block_forward
+ ):
+ raise TypeError(
+ f"MiniMax H3 {block_name} has an inconsistent in-forward "
+ "checkpoint installation"
+ )
+ configured += 1
+ continue
+ if shadowed_forward is not None:
+ raise TypeError(
+ f"MiniMax H3 {block_name} must not shadow forward before in-forward "
+ "checkpoint installation"
+ )
+ original_forward = type(block).forward
+ if not callable(original_forward):
+ raise TypeError(
+ f"MiniMax H3 {block_name} expected a callable class forward, received "
+ f"{original_forward!r}"
+ )
+ block.flow_factory_uncheckpointed_forward = original_forward
+ block.forward = MethodType(_checkpoint_h3_block_forward, block)
+ configured += 1
+ return configured
+
+
def install_h3_attention_norm_chunking(
transformer: nn.Module,
*,
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index 411077c05..db5a6a633 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -50,6 +50,7 @@
from ._chunking import (
H3_MAX_LORA_PROJECTION_TOKENS,
H3_MAX_ROTARY_TOKENS,
+ install_h3_in_forward_block_checkpointing,
install_h3_lora_projection_chunking,
install_h3_rotary_chunking,
)
@@ -154,6 +155,26 @@ def build_component_runtime(self) -> ModularPipelineRuntime:
)
return runtime
+ def configure_fsdp2_in_forward_activation_checkpointing(
+ self,
+ model_root: torch.nn.Module,
+ ) -> int:
+ """Install one inner checkpoint on every materialized H3 block variant."""
+ members = getattr(model_root, "members", None)
+ if not isinstance(members, torch.nn.ModuleDict):
+ raise TypeError(
+ "MiniMax H3 FSDP2 checkpointing expected a ModelBundle ModuleDict, "
+ f"received {type(members).__name__}"
+ )
+ configured = 0
+ seen = set()
+ for component in members.values():
+ if id(component) in seen:
+ continue
+ seen.add(id(component))
+ configured += install_h3_in_forward_block_checkpointing(component)
+ return configured
+
def load_scheduler(self) -> MiniMaxH3SDEScheduler:
"""Build the canonical shift-12 video scheduler."""
return build_h3_scheduler(self.config.scheduler_args, shift=12.0)
@@ -375,6 +396,8 @@ class MiniMaxH3Ref2VAAdapter(_MiniMaxH3WorkflowAdapter, BaseAdapter):
workflow: ClassVar[str] = "ref2va"
transformer_component_name: ClassVar[str] = "transformer_ref"
fsdp2_use_default_stream_unshard: ClassVar[bool] = True
+ fsdp2_use_in_forward_activation_checkpointing: ClassVar[bool] = True
+ fsdp2_disable_backward_prefetch: ClassVar[bool] = True
fsdp2_additional_wrap_module_names: ClassVar[Tuple[str, ...]] = (
"_ChunkedFeedForward",
"MiniMaxH3AdaLayerNormModulation",
diff --git a/tests/loading/test_backend_runtime.py b/tests/loading/test_backend_runtime.py
index 14a8ed357..586836d01 100644
--- a/tests/loading/test_backend_runtime.py
+++ b/tests/loading/test_backend_runtime.py
@@ -113,10 +113,14 @@ class _FSDPModuleFake(torch.nn.Linear):
def __init__(self) -> None:
super().__init__(2, 2)
self.unshard_async_op_calls = []
+ self.backward_prefetch_calls = []
def _set_unshard_async_op(self, enabled: bool) -> None:
self.unshard_async_op_calls.append(enabled)
+ def set_modules_to_backward_prefetch(self, modules) -> None:
+ self.backward_prefetch_calls.append(modules)
+
def test_prepare_enables_adapter_requested_fsdp2_default_stream_unshard() -> None:
module = _FSDPModuleFake()
@@ -133,6 +137,20 @@ def test_prepare_enables_adapter_requested_fsdp2_default_stream_unshard() -> Non
assert module.unshard_async_op_calls == [True]
+def test_prepare_disables_default_fsdp2_backward_prefetch_recursively() -> None:
+ module = _FSDPModuleFake()
+ module.child = _FSDPModuleFake()
+ runtime = FSDPBackendLoadRuntime(
+ _AcceleratorFake(efficient=True),
+ _plan(),
+ SimpleNamespace(fsdp2_disable_backward_prefetch=True),
+ )
+
+ assert runtime.prepare(module) is module
+ assert module.backward_prefetch_calls == [[module]]
+ assert module.child.backward_prefetch_calls == [[module.child]]
+
+
def test_prepare_extends_fsdp2_wrap_policy_before_distributed_preparation() -> None:
module = _FSDPModuleFake()
module._no_split_modules = ["TransformerBlock"]
@@ -152,6 +170,43 @@ def test_prepare_extends_fsdp2_wrap_policy_before_distributed_preparation() -> N
]
+def test_prepare_delegates_fsdp2_activation_checkpointing_during_prepare() -> None:
+ module = _FSDPModuleFake()
+ module._no_split_modules = ["TransformerBlock"]
+ accelerator = _AcceleratorFake(efficient=True)
+ plugin = accelerator.state.fsdp_plugin
+ plugin.activation_checkpointing = True
+ prepare_observations = []
+ accelerator.prepare = (
+ lambda *objects: prepare_observations.append(
+ (
+ plugin.activation_checkpointing,
+ tuple(plugin.transformer_cls_names_to_wrap),
+ )
+ )
+ or objects[0]
+ )
+ configured_roots = []
+ runtime = FSDPBackendLoadRuntime(
+ accelerator,
+ _plan(),
+ SimpleNamespace(
+ fsdp2_additional_wrap_module_names=("ChunkedFeedForward",),
+ fsdp2_use_in_forward_activation_checkpointing=True,
+ configure_fsdp2_in_forward_activation_checkpointing=lambda root: (
+ configured_roots.append(root) or 2
+ ),
+ ),
+ )
+
+ assert runtime.prepare(module) is module
+ assert configured_roots == [module]
+ assert prepare_observations == [
+ (False, ("TransformerBlock", "ChunkedFeedForward")),
+ ]
+ assert plugin.activation_checkpointing is True
+
+
@pytest.mark.parametrize(
("fsdp_version", "requested"),
[(1, True), (2, False)],
diff --git a/tests/models/minimax_h3/test_chunking.py b/tests/models/minimax_h3/test_chunking.py
index f17f5c3db..a0342228c 100644
--- a/tests/models/minimax_h3/test_chunking.py
+++ b/tests/models/minimax_h3/test_chunking.py
@@ -35,6 +35,7 @@
_ChunkedRMSNorm,
install_h3_attention_norm_chunking,
install_h3_feed_forward_chunking,
+ install_h3_in_forward_block_checkpointing,
install_h3_lora_projection_chunking,
install_h3_rotary_chunking,
)
@@ -83,6 +84,9 @@ def __init__(self) -> None:
self.attn.processor = MiniMaxH3AttnProcessor()
self.ff = FeedForwardFake()
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ return self.ff(hidden_states)
+
class TransformerFake(nn.Module):
def __init__(self) -> None:
@@ -137,6 +141,77 @@ def test_chunking_preserves_parameter_tree_and_is_idempotent() -> None:
assert not any("inner" in name for name, _ in transformer.named_modules())
+def test_in_forward_checkpointing_preserves_state_and_backward() -> None:
+ torch.manual_seed(13)
+ direct = TransformerFake().double()
+ checkpointed = deepcopy(direct)
+ state_keys = tuple(checkpointed.state_dict())
+ parameter_ids = {name: id(parameter) for name, parameter in checkpointed.named_parameters()}
+
+ assert install_h3_in_forward_block_checkpointing(checkpointed) == 5
+ assert install_h3_in_forward_block_checkpointing(checkpointed) == 5
+ assert tuple(checkpointed.state_dict()) == state_keys
+ assert {
+ name: id(parameter) for name, parameter in checkpointed.named_parameters()
+ } == parameter_ids
+
+ direct_input = torch.randn(2, 7, 5, dtype=torch.float64, requires_grad=True)
+ checkpointed_input = direct_input.detach().clone().requires_grad_(True)
+ direct_block = direct.transformer_blocks[0]
+ checkpointed_block = checkpointed.transformer_blocks[0]
+ direct_output = direct_block(direct_input)
+ checkpointed_output = checkpointed_block(checkpointed_input)
+ output_gradient = torch.randn_like(direct_output)
+ direct_output.backward(output_gradient)
+ checkpointed_output.backward(output_gradient)
+
+ torch.testing.assert_close(checkpointed_output, direct_output)
+ torch.testing.assert_close(checkpointed_input.grad, direct_input.grad)
+ for direct_parameter, checkpointed_parameter in zip(
+ direct_block.parameters(), checkpointed_block.parameters()
+ ):
+ torch.testing.assert_close(checkpointed_parameter.grad, direct_parameter.grad)
+
+
+def test_in_forward_checkpoint_starts_after_module_pre_forward_hook(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ transformer = TransformerFake().double()
+ install_h3_in_forward_block_checkpointing(transformer)
+ block = transformer.transformer_blocks[0]
+ checkpoint_dtypes = []
+ save_on_cpu_calls = []
+
+ class RecordingSaveOnCPU:
+ def __init__(self, *, pin_memory: bool, device_type: str) -> None:
+ save_on_cpu_calls.append((pin_memory, device_type))
+
+ def __enter__(self) -> None:
+ return None
+
+ def __exit__(self, *exc_info) -> None:
+ return None
+
+ monkeypatch.setattr(
+ "flow_factory.models.minimax_h3._chunking.save_on_cpu",
+ RecordingSaveOnCPU,
+ )
+ monkeypatch.setattr(
+ "flow_factory.models.minimax_h3._chunking.checkpoint",
+ lambda function, owner, hidden_states, **kwargs: checkpoint_dtypes.append(
+ hidden_states.dtype
+ )
+ or function(owner, hidden_states),
+ )
+ handle = block.register_forward_pre_hook(lambda _module, inputs: (inputs[0].to(torch.float64),))
+
+ block(torch.randn(2, 7, 5, dtype=torch.float32, requires_grad=True))
+ handle.remove()
+
+ assert checkpoint_dtypes == [torch.float64]
+ assert save_on_cpu_calls == [(False, "cpu")]
+
+
def test_chunking_handles_remainder_and_preserves_forward_backward() -> None:
torch.manual_seed(17)
direct = TransformerFake().double()
diff --git a/tests/models/minimax_h3/test_workflow_adapters.py b/tests/models/minimax_h3/test_workflow_adapters.py
index 33bea489d..da9dac2cc 100644
--- a/tests/models/minimax_h3/test_workflow_adapters.py
+++ b/tests/models/minimax_h3/test_workflow_adapters.py
@@ -44,14 +44,20 @@ class UpstreamSchedulerFake:
def test_only_ref2va_requests_fsdp2_default_stream_unshard() -> None:
assert MiniMaxH3Ref2VAAdapter.fsdp2_use_default_stream_unshard
+ assert MiniMaxH3Ref2VAAdapter.fsdp2_use_in_forward_activation_checkpointing
+ assert MiniMaxH3Ref2VAAdapter.fsdp2_disable_backward_prefetch
assert MiniMaxH3Ref2VAAdapter.fsdp2_additional_wrap_module_names == (
"_ChunkedFeedForward",
"MiniMaxH3AdaLayerNormModulation",
"MiniMaxH3Attention",
)
assert not MiniMaxH3T2VAAdapter.fsdp2_use_default_stream_unshard
+ assert not MiniMaxH3T2VAAdapter.fsdp2_use_in_forward_activation_checkpointing
+ assert not MiniMaxH3T2VAAdapter.fsdp2_disable_backward_prefetch
assert not MiniMaxH3T2VAAdapter.fsdp2_additional_wrap_module_names
assert not MiniMaxH3FL2VAAdapter.fsdp2_use_default_stream_unshard
+ assert not MiniMaxH3FL2VAAdapter.fsdp2_use_in_forward_activation_checkpointing
+ assert not MiniMaxH3FL2VAAdapter.fsdp2_disable_backward_prefetch
assert not MiniMaxH3FL2VAAdapter.fsdp2_additional_wrap_module_names
From b6c8ffd6444bc099890ea7f7090f3e54a14bd4e9 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 01:43:44 +0800
Subject: [PATCH 60/76] [loading] fix: unshard repeated FSDP2 checkpoint
replays
---
.agents/knowledge/topics/fix_patterns.md | 19 ++++++++++
src/flow_factory/loading/backend.py | 46 +++++++++++++++++++++++-
tests/loading/test_backend_runtime.py | 35 ++++++++++++++++++
3 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index ef927458a..2c6c1a173 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -672,6 +672,25 @@ Based on the fix type, write the fix entry to the appropriate document:
parameter sharding may multiply activation lifetimes or overlap adjacent full-parameter units.
- **Related Constraint**: #9, #20
+### Nested FSDP2 checkpoint replay must verify that parameters remain unsharded
+- **Date**: 2026-08-31
+- **Symptom**: MiniMax H3 Ref2VA FSDP2 offline DPO completed both policy-arm forwards but
+ failed during the second checkpoint replay with `got mixed torch.Tensor and DTensor`.
+- **Root Cause**: PyTorch FSDP2 releases a nested unit after the first arm's post-backward while
+ its state remains `PRE_BACKWARD`; the second arm's checkpoint replay therefore takes the
+ pre-forward early return and uses sharded DTensor parameters with ordinary tensor inputs. This
+ is the upstream PyTorch issue #153354, fixed only after PyTorch 2.10 by commit 6579652.
+- **Fix**: Adapter-owned FSDP2 activation checkpointing now appends a post-prepare pre-forward
+ hook to every prepared FSDP unit. The hook calls the public synchronous `unshard()` API after
+ FSDP's own pre-forward hook, which is a no-op for normal forwards and restores parameters only
+ when an earlier checkpoint graph has already resharded them. A two-rank nested-FSDP reproducer
+ now completes two forward graphs and one combined backward without changing DPO semantics.
+- **Lesson**: A distributed training state is not proof of parameter residency. Multiple
+ checkpointed forwards may interleave one unit's post-backward with another graph's replay, so a
+ compatibility backport must repair residency at the FSDP lifecycle boundary rather than split
+ a coupled objective or expose the workaround in algorithm code.
+- **Related Constraint**: #9, #20
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/loading/backend.py b/src/flow_factory/loading/backend.py
index 56d3e3a71..388ade55d 100644
--- a/src/flow_factory/loading/backend.py
+++ b/src/flow_factory/loading/backend.py
@@ -32,6 +32,15 @@
logger = setup_logger(__name__)
+def _unshard_fsdp2_checkpoint_replay(module: nn.Module, _inputs: Any) -> None:
+ """Backport the nested-FSDP multi-forward replay fix through public APIs."""
+ unshard = getattr(module, "unshard", None)
+ if not callable(unshard):
+ module_type = type(module).__name__
+ raise TypeError(f"FSDP2 checkpoint replay hook requires unshard(), received {module_type}")
+ unshard()
+
+
def configure_backend_loading(accelerator: Accelerator, adapter_class: type) -> None:
"""Apply adapter capabilities before any pretrained component is loaded."""
if accelerator.distributed_type != DistributedType.FSDP:
@@ -173,7 +182,11 @@ def prepare(self, *objects: Any) -> Any:
disable_backward_prefetch = bool(
getattr(self.adapter, "fsdp2_disable_backward_prefetch", False)
)
- if not use_default_stream and not disable_backward_prefetch:
+ if (
+ not use_in_forward_checkpointing
+ and not use_default_stream
+ and not disable_backward_prefetch
+ ):
return prepared
prepared_objects = prepared if isinstance(prepared, (list, tuple)) else (prepared,)
@@ -186,6 +199,37 @@ def prepare(self, *objects: Any) -> Any:
if not prepared_modules:
raise TypeError("FSDP2 memory policy requested without a prepared module")
+ if use_in_forward_checkpointing:
+ configured = 0
+ newly_registered = 0
+ for root in prepared_modules:
+ for module in root.modules():
+ if not callable(getattr(module, "unshard", None)):
+ continue
+ configured += 1
+ if any(
+ hook is _unshard_fsdp2_checkpoint_replay
+ for hook in module._forward_pre_hooks.values()
+ ):
+ continue
+ # PyTorch <=2.10 returns early from its FSDP pre-forward hook
+ # during activation-checkpoint replay. With two forward graphs,
+ # a preceding post-backward may already have resharded this unit.
+ # Appending this hook after FSDP's hook mirrors the upstream fix
+ # and makes the public call a no-op when parameters remain full.
+ module.register_forward_pre_hook(_unshard_fsdp2_checkpoint_replay)
+ newly_registered += 1
+ if configured < 1:
+ raise TypeError(
+ "adapter-owned FSDP2 checkpointing requires prepared FSDPModule roots"
+ )
+ logger.info(
+ "Enabled nested FSDP2 multi-forward checkpoint replay unshard: "
+ "configured=%d, newly_registered=%d",
+ configured,
+ newly_registered,
+ )
+
if use_default_stream:
configured = []
for candidate in prepared_modules:
diff --git a/tests/loading/test_backend_runtime.py b/tests/loading/test_backend_runtime.py
index 586836d01..c367b45ef 100644
--- a/tests/loading/test_backend_runtime.py
+++ b/tests/loading/test_backend_runtime.py
@@ -114,6 +114,7 @@ def __init__(self) -> None:
super().__init__(2, 2)
self.unshard_async_op_calls = []
self.backward_prefetch_calls = []
+ self.replay_hook_events = []
def _set_unshard_async_op(self, enabled: bool) -> None:
self.unshard_async_op_calls.append(enabled)
@@ -121,6 +122,9 @@ def _set_unshard_async_op(self, enabled: bool) -> None:
def set_modules_to_backward_prefetch(self, modules) -> None:
self.backward_prefetch_calls.append(modules)
+ def unshard(self) -> None:
+ self.replay_hook_events.append("unshard")
+
def test_prepare_enables_adapter_requested_fsdp2_default_stream_unshard() -> None:
module = _FSDPModuleFake()
@@ -172,6 +176,13 @@ def test_prepare_extends_fsdp2_wrap_policy_before_distributed_preparation() -> N
def test_prepare_delegates_fsdp2_activation_checkpointing_during_prepare() -> None:
module = _FSDPModuleFake()
+ module.child = _FSDPModuleFake()
+ module.register_forward_pre_hook(
+ lambda _module, _inputs: module.replay_hook_events.append("fsdp-pre-forward")
+ )
+ module.child.register_forward_pre_hook(
+ lambda _module, _inputs: module.child.replay_hook_events.append("fsdp-pre-forward")
+ )
module._no_split_modules = ["TransformerBlock"]
accelerator = _AcceleratorFake(efficient=True)
plugin = accelerator.state.fsdp_plugin
@@ -205,6 +216,30 @@ def test_prepare_delegates_fsdp2_activation_checkpointing_during_prepare() -> No
(False, ("TransformerBlock", "ChunkedFeedForward")),
]
assert plugin.activation_checkpointing is True
+ module(torch.ones(1, 2))
+ assert module.replay_hook_events == ["fsdp-pre-forward", "unshard"]
+ module.child(torch.ones(1, 2))
+ assert module.child.replay_hook_events == ["fsdp-pre-forward", "unshard"]
+
+
+def test_prepare_replay_unshard_hook_is_idempotent() -> None:
+ module = _FSDPModuleFake()
+ accelerator = _AcceleratorFake(efficient=True)
+ accelerator.state.fsdp_plugin.activation_checkpointing = True
+ runtime = FSDPBackendLoadRuntime(
+ accelerator,
+ _plan(),
+ SimpleNamespace(
+ fsdp2_use_in_forward_activation_checkpointing=True,
+ configure_fsdp2_in_forward_activation_checkpointing=lambda _root: 1,
+ ),
+ )
+
+ assert runtime.prepare(module) is module
+ assert runtime.prepare(module) is module
+ module(torch.ones(1, 2))
+
+ assert module.replay_hook_events == ["unshard"]
@pytest.mark.parametrize(
From c84bfae051c5cc49197d238f659112ca6217fe46 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 03:14:29 +0800
Subject: [PATCH 61/76] [trainer] fix: reject unsupported optimizers before
model load
---
.agents/knowledge/topics/fix_patterns.md | 14 +++
src/flow_factory/trainers/loader.py | 4 +-
.../trainers/multirole/__init__.py | 2 +
.../trainers/multirole/backend.py | 65 +++++++----
.../test_distributed_plan_validation.py | 108 ++++++++++++------
tests/trainers/test_multirole_loader.py | 1 +
6 files changed, 133 insertions(+), 61 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 2c6c1a173..37b7202ff 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -691,6 +691,20 @@ Based on the fix type, write the fix entry to the appropriate document:
a coupled objective or expose the workaround in algorithm code.
- **Related Constraint**: #9, #20
+### Optimizer/backend compatibility must fail before model loading
+- **Date**: 2026-08-31
+- **Symptom**: A Muon run configured with DeepSpeed ZeRO-2 failed with the intended compatibility
+ error only after SD3.5 weights, LoRA adapters, and training data had been loaded.
+- **Root Cause**: Optimizer/backend validation lived only in `_init_optimizer`, whose lifecycle
+ position is necessarily after model adapter construction and data preprocessing.
+- **Fix**: The compatibility contract is now one shared backend-plan validator called by the
+ trainer loader before model construction and defensively called again before optimizer
+ construction. The two lifecycle gates therefore cannot drift to different rules.
+- **Lesson**: Validate compatibility from configuration as soon as the runtime backend is known.
+ Keep a second check at the resource-construction boundary, but delegate both checks to one
+ implementation so early rejection does not create a parallel source of truth.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/src/flow_factory/trainers/loader.py b/src/flow_factory/trainers/loader.py
index 4554f342a..2554c8c7a 100644
--- a/src/flow_factory/trainers/loader.py
+++ b/src/flow_factory/trainers/loader.py
@@ -31,7 +31,8 @@
from ..models.registry import get_model_adapter_class
from ..utils.env_utils import reconcile_config
from ..utils.logger_utils import setup_logger
-from .abc import BaseTrainer, validate_supported_distributed_plan
+from .abc import BaseTrainer
+from .multirole import validate_optimizer_backend_plan, validate_supported_distributed_plan
from .registry import get_trainer_class, list_registered_trainers
logger = setup_logger(__name__)
@@ -137,6 +138,7 @@ def load_trainer(config: Arguments) -> BaseTrainer:
# constructing an adapter under ZeRO-3 can shard parameters immediately, so
# rejecting it in BaseTrainer.__init__ is too late.
validate_supported_distributed_plan(accelerator)
+ validate_optimizer_backend_plan(accelerator, tuple(config.optimizer_args))
set_seed(config.training_args.seed, device_specific=True)
# Reconcile config with runtime distributed state (before any consumer reads it)
diff --git a/src/flow_factory/trainers/multirole/__init__.py b/src/flow_factory/trainers/multirole/__init__.py
index abeaf5475..1649ff235 100644
--- a/src/flow_factory/trainers/multirole/__init__.py
+++ b/src/flow_factory/trainers/multirole/__init__.py
@@ -3,6 +3,7 @@
from .backend import (
MultiRoleBackendValidationMixin,
configure_deepspeed_micro_batch_size,
+ validate_optimizer_backend_plan,
validate_supported_distributed_plan,
)
from .checkpointing import MULTIROLE_RUNTIME_CHILD_NAME, MultiRoleCheckpointingMixin
@@ -12,5 +13,6 @@
"MultiRoleCheckpointingMixin",
"MULTIROLE_RUNTIME_CHILD_NAME",
"configure_deepspeed_micro_batch_size",
+ "validate_optimizer_backend_plan",
"validate_supported_distributed_plan",
]
diff --git a/src/flow_factory/trainers/multirole/backend.py b/src/flow_factory/trainers/multirole/backend.py
index 323d7a49f..4a04813c6 100644
--- a/src/flow_factory/trainers/multirole/backend.py
+++ b/src/flow_factory/trainers/multirole/backend.py
@@ -25,6 +25,47 @@ def validate_supported_distributed_plan(accelerator: Accelerator) -> None:
)
+def validate_optimizer_backend_plan(
+ accelerator: Accelerator,
+ optimizer_args: Sequence[OptimizerArguments],
+) -> None:
+ """Reject optimizer/backend pairings before pretrained weights are loaded.
+
+ Args:
+ accelerator: Runtime backend whose optimizer support is being validated.
+ optimizer_args: Parsed optimizer configurations for every trainable role.
+
+ Returns:
+ None.
+
+ Raises:
+ ValueError: If Muon is paired with DeepSpeed or FSDP1.
+ """
+ if not uses_muon(optimizer_args):
+ return
+ if accelerator.distributed_type == DistributedType.DEEPSPEED:
+ raise ValueError(
+ "Muon with DeepSpeed is not verified in this framework: Muon rejects "
+ "non-matrix parameters, so it runs inside a CompositeOptimizer, and "
+ "DeepSpeed rebuilds its own optimizer wrapper around the object it "
+ "receives. Use DDP or FSDP2 with Muon, or select the adamw optimizer."
+ )
+ if accelerator.distributed_type != DistributedType.FSDP:
+ return
+ fsdp_plugin = getattr(accelerator.state, "fsdp_plugin", None)
+ fsdp_version = getattr(fsdp_plugin, "fsdp_version", 1) if fsdp_plugin else 1
+ if fsdp_version >= 2:
+ return
+ raise ValueError(
+ "Muon with FSDP1 does not work: FSDP1 flattens each wrapped unit into a "
+ "1D FlatParameter, so Muon is constructed over matrices and then receives "
+ "a 1D gradient, failing with 'Param gradient must be a 2D matrix' at the "
+ "first optimizer step. Set `fsdp_version: 2` in the accelerate config "
+ "(config/accelerate_configs/fsdp2.yaml), use DDP, or select the adamw "
+ "optimizer."
+ )
+
+
def configure_deepspeed_micro_batch_size(
accelerator: Accelerator, per_device_batch_size: int
) -> None:
@@ -61,29 +102,7 @@ def _validate_optimizer_backend(
optimizer_args: Sequence[OptimizerArguments],
) -> None:
"""Reject optimizer and distributed-backend pairings that are not verified."""
- if not uses_muon(optimizer_args):
- return
- if self.accelerator.distributed_type == DistributedType.DEEPSPEED:
- raise ValueError(
- "Muon with DeepSpeed is not verified in this framework: Muon rejects "
- "non-matrix parameters, so it runs inside a CompositeOptimizer, and "
- "DeepSpeed rebuilds its own optimizer wrapper around the object it "
- "receives. Use DDP or FSDP2 with Muon, or select the adamw optimizer."
- )
- if self.accelerator.distributed_type != DistributedType.FSDP:
- return
- fsdp_plugin = getattr(self.accelerator.state, "fsdp_plugin", None)
- fsdp_version = getattr(fsdp_plugin, "fsdp_version", 1) if fsdp_plugin else 1
- if fsdp_version >= 2:
- return
- raise ValueError(
- "Muon with FSDP1 does not work: FSDP1 flattens each wrapped unit into a "
- "1D FlatParameter, so Muon is constructed over matrices and then receives "
- "a 1D gradient, failing with 'Param gradient must be a 2D matrix' at the "
- "first optimizer step. Set `fsdp_version: 2` in the accelerate config "
- "(config/accelerate_configs/fsdp2.yaml), use DDP, or select the adamw "
- "optimizer."
- )
+ validate_optimizer_backend_plan(self.accelerator, optimizer_args)
def _validate_trainable_parameters_survived_prepare(self) -> None:
"""Reject a prepared root that no rank can train."""
diff --git a/tests/trainers/test_distributed_plan_validation.py b/tests/trainers/test_distributed_plan_validation.py
index 0766c89f3..0ca9d46a7 100644
--- a/tests/trainers/test_distributed_plan_validation.py
+++ b/tests/trainers/test_distributed_plan_validation.py
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import inspect
from pathlib import Path
from types import SimpleNamespace
@@ -19,7 +20,13 @@
import torch
from accelerate.utils import DistributedType
+from flow_factory.hparams.optimizer_args import (
+ AdamWOptimizerArguments,
+ MuonOptimizerArguments,
+)
+from flow_factory.trainers import loader
from flow_factory.trainers.abc import (
+ BaseTrainer,
configure_deepspeed_micro_batch_size,
validate_supported_distributed_plan,
)
@@ -43,8 +50,6 @@ def test_zero_three_is_rejected_before_any_weights_load() -> None:
def test_loader_rejects_zero_three_before_loading_model(monkeypatch: pytest.MonkeyPatch) -> None:
"""The trainer factory must reject ZeRO-3 before constructing an adapter."""
- from flow_factory.trainers import loader
-
accelerator = _accelerator(DistributedType.DEEPSPEED, zero_stage=3)
model_load_attempted = False
@@ -59,6 +64,7 @@ def unexpected_model_load(**kwargs: object) -> None:
config = SimpleNamespace(
mixed_precision="bf16",
+ optimizer_args=(),
model_args=SimpleNamespace(model_type="test"),
log_args=SimpleNamespace(save_dir="/tmp", run_name="zero3-rejection-test"),
training_args=SimpleNamespace(
@@ -81,6 +87,69 @@ def unexpected_model_load(**kwargs: object) -> None:
assert model_load_attempted is False
+def _fsdp_accelerator(fsdp_version: int) -> SimpleNamespace:
+ """Build an Accelerator stub reporting the requested FSDP major version."""
+ accelerator = _accelerator(DistributedType.FSDP)
+ accelerator.state.fsdp_plugin = SimpleNamespace(fsdp_version=fsdp_version)
+ return accelerator
+
+
+@pytest.mark.parametrize(
+ ("accelerator", "error_pattern", "run_name"),
+ [
+ (
+ _accelerator(DistributedType.DEEPSPEED, zero_stage=2),
+ "Muon with DeepSpeed is not verified",
+ "muon-deepspeed-rejection-test",
+ ),
+ (
+ _fsdp_accelerator(fsdp_version=1),
+ "Muon with FSDP1 does not work",
+ "muon-fsdp1-rejection-test",
+ ),
+ ],
+)
+def test_loader_rejects_unsupported_muon_backend_before_loading_model(
+ monkeypatch: pytest.MonkeyPatch,
+ accelerator: SimpleNamespace,
+ error_pattern: str,
+ run_name: str,
+) -> None:
+ """The trainer factory must reject unsupported Muon plans before model loading."""
+ model_load_attempted = False
+
+ class Adapter:
+ ddp_find_unused_parameters = False
+
+ def unexpected_model_load(**kwargs: object) -> None:
+ del kwargs
+ nonlocal model_load_attempted
+ model_load_attempted = True
+ raise AssertionError("load_model must not run for an unsupported Muon backend")
+
+ config = SimpleNamespace(
+ mixed_precision="bf16",
+ optimizer_args=(MuonOptimizerArguments(name="base"),),
+ model_args=SimpleNamespace(model_type="test"),
+ log_args=SimpleNamespace(save_dir="/tmp", run_name=run_name),
+ training_args=SimpleNamespace(
+ gradient_accumulation_steps=1,
+ max_grad_norm=1.0,
+ seed=42,
+ trainer_type="grpo",
+ required_trainable_roles=None,
+ ),
+ )
+ monkeypatch.setattr(loader, "get_model_adapter_class", lambda model_type: Adapter)
+ monkeypatch.setattr(loader, "Accelerator", lambda **kwargs: accelerator)
+ monkeypatch.setattr(loader, "load_model", unexpected_model_load)
+
+ with pytest.raises(ValueError, match=error_pattern):
+ loader.load_trainer(config)
+
+ assert model_load_attempted is False
+
+
@pytest.mark.parametrize("zero_stage", [1, 2])
def test_supported_deepspeed_stages_pass(zero_stage: int) -> None:
"""ZeRO-1 and ZeRO-2 are the supported DeepSpeed configurations."""
@@ -114,12 +183,6 @@ def test_deepspeed_micro_batch_size_is_set_for_custom_train_loader() -> None:
def test_muon_with_deepspeed_is_rejected_as_unverified() -> None:
"""Muon runs inside a composite; DeepSpeed rebuilds its own optimizer wrapper."""
- from flow_factory.hparams.optimizer_args import (
- AdamWOptimizerArguments,
- MuonOptimizerArguments,
- )
- from flow_factory.trainers.abc import BaseTrainer
-
trainer = SimpleNamespace(accelerator=_accelerator(DistributedType.DEEPSPEED, zero_stage=2))
with pytest.raises(ValueError, match="Muon with DeepSpeed is not verified"):
@@ -131,21 +194,8 @@ def test_muon_with_deepspeed_is_rejected_as_unverified() -> None:
BaseTrainer._validate_optimizer_backend(fsdp2_trainer, (MuonOptimizerArguments(name="base"),))
-def _fsdp_accelerator(fsdp_version: int) -> SimpleNamespace:
- """Accelerator reporting an FSDP plan of the requested major version."""
- accelerator = _accelerator(DistributedType.FSDP)
- accelerator.state.fsdp_plugin = SimpleNamespace(fsdp_version=fsdp_version)
- return accelerator
-
-
def test_muon_with_fsdp1_is_rejected_before_a_rollout_is_paid_for() -> None:
"""FSDP1 flattens to 1D, so Muon would only fail after the first full rollout."""
- from flow_factory.hparams.optimizer_args import (
- AdamWOptimizerArguments,
- MuonOptimizerArguments,
- )
- from flow_factory.trainers.abc import BaseTrainer
-
trainer = SimpleNamespace(accelerator=_fsdp_accelerator(fsdp_version=1))
with pytest.raises(ValueError, match="Muon with FSDP1 does not work"):
@@ -170,10 +220,6 @@ def test_deepspeed_gradient_clipping_is_wired_from_the_configured_norm() -> None
plugin, so leaving it unset ships an unresolved "auto" and max_grad_norm never
takes effect on that backend.
"""
- import inspect
-
- from flow_factory.trainers import loader
-
source = inspect.getsource(loader.load_trainer)
assert "ACCELERATE_GRADIENT_CLIPPING" in source
assert source.index("ACCELERATE_GRADIENT_CLIPPING") < source.index("accelerator = Accelerator(")
@@ -185,8 +231,6 @@ def _prepared_trainer(distributed_type: DistributedType, local: int, others: int
``reduce`` stands in for the collective: the guard asks whether ANY rank holds
trainable elements, so the stub adds what the peers would report.
"""
- from flow_factory.trainers.abc import BaseTrainer
-
accelerator = _accelerator(distributed_type)
accelerator.device = torch.device("cpu")
accelerator.num_processes = 2
@@ -220,8 +264,6 @@ def test_a_rank_holding_no_shard_of_the_adapter_is_accepted() -> None:
def test_tdm_r1_fsdp1_disables_incompatible_activation_checkpointing() -> None:
- from flow_factory.trainers.abc import BaseTrainer
-
plugin = SimpleNamespace(fsdp_version=1, activation_checkpointing=True)
disabled = []
trainer = SimpleNamespace(
@@ -250,8 +292,6 @@ def test_tdm_r1_fsdp1_disables_incompatible_activation_checkpointing() -> None:
def test_fsdp2_disables_model_checkpointing_and_keeps_backend_checkpointing(
checkpoint_policy: object,
) -> None:
- from flow_factory.trainers.abc import BaseTrainer
-
plugin = SimpleNamespace(fsdp_version=2, activation_checkpointing=True)
disabled = []
trainer = SimpleNamespace(
@@ -274,8 +314,6 @@ def test_fsdp2_disables_model_checkpointing_and_keeps_backend_checkpointing(
def test_fsdp1_keeps_model_checkpointing_and_disables_nested_backend_checkpointing() -> None:
- from flow_factory.trainers.abc import BaseTrainer
-
plugin = SimpleNamespace(fsdp_version=1, activation_checkpointing=True)
trainer = SimpleNamespace(
accelerator=SimpleNamespace(
@@ -300,8 +338,6 @@ def test_fsdp1_keeps_model_checkpointing_and_disables_nested_backend_checkpointi
def test_fsdp2_rejects_selective_model_and_backend_checkpointing() -> None:
- from flow_factory.trainers.abc import BaseTrainer
-
plugin = SimpleNamespace(fsdp_version=2, activation_checkpointing=True)
trainer = SimpleNamespace(
accelerator=SimpleNamespace(
@@ -327,8 +363,6 @@ def test_fsdp2_rejects_selective_model_and_backend_checkpointing() -> None:
def test_fsdp2_keeps_backend_checkpointing_when_model_policy_is_disabled() -> None:
- from flow_factory.trainers.abc import BaseTrainer
-
plugin = SimpleNamespace(fsdp_version=2, activation_checkpointing=True)
trainer = SimpleNamespace(
accelerator=SimpleNamespace(
diff --git a/tests/trainers/test_multirole_loader.py b/tests/trainers/test_multirole_loader.py
index 9645726ee..693b9a1fd 100644
--- a/tests/trainers/test_multirole_loader.py
+++ b/tests/trainers/test_multirole_loader.py
@@ -52,6 +52,7 @@ def _config(
) -> SimpleNamespace:
return SimpleNamespace(
mixed_precision="no",
+ optimizer_args=(),
model_args=SimpleNamespace(model_type="tiny"),
training_args=SimpleNamespace(
trainer_type=trainer_type,
From 7501d1585fea30e58df07cb18edd202ac4a3cf2b Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 03:23:45 +0800
Subject: [PATCH 62/76] [samples] refactor: enforce reconstruction at gather
boundary
---
.agents/knowledge/topics/fix_patterns.md | 5 ++++-
src/flow_factory/rewards/reward_processor.py | 5 -----
src/flow_factory/utils/dist.py | 7 +++++--
tests/trainers/test_collective_packing.py | 18 +++++++++++++++++-
4 files changed, 26 insertions(+), 9 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 37b7202ff..1f946ecf2 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -58,7 +58,10 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Date**: 2026-08-30
- **Symptom**: Two-rank H3 Ref2VA GRPO failed before reward execution because `MiniMaxH3Ref2VASample` was reconstructed with `reference_manifest=None`.
- **Root Cause**: The distributed group-reward path gathered only reward-consumed fields, although `gather_samples` reconstructs the concrete sample class and that class can require additional state.
-- **Fix**: `BaseSample` now declares an empty `reconstruction_required_fields` contract, `OrderedReferenceConditionSample` adds `reference_manifest`, and `RewardProcessor` unions that contract into its distributed gather fields without forwarding it to the reward call.
+- **Fix**: `BaseSample` now declares an empty `reconstruction_required_fields` contract,
+ `OrderedReferenceConditionSample` adds `reference_manifest`, and `gather_samples` automatically
+ unions that contract at the concrete reconstruction boundary. Reward callers therefore transport
+ constructor state without forwarding it to the reward call or duplicating gather policy.
- **Lesson**: Communication payload requirements and reward-call requirements are distinct contracts; partial gathers must preserve constructor invariants even for fields that downstream computation does not consume.
- **Related Constraint**: N/A
diff --git a/src/flow_factory/rewards/reward_processor.py b/src/flow_factory/rewards/reward_processor.py
index 8632ff06c..ccd000867 100644
--- a/src/flow_factory/rewards/reward_processor.py
+++ b/src/flow_factory/rewards/reward_processor.py
@@ -474,11 +474,6 @@ def _compute_groupwise_distributed(
for model in models.values():
required_fields.update(model.required_fields)
- # ``gather_samples`` reconstructs the concrete sample class from the
- # transported fields. Preserve fields required by that class's constructor
- # invariants even when the reward itself does not consume them.
- required_fields.update(type(samples[0]).reconstruction_required_fields)
-
# Always include the typed source bookkeeping — the gate needs
# `source` (and ideally `source_id`) on the gathered side. Now
# that they're real dataclass fields on `BaseSample`, gathering
diff --git a/src/flow_factory/utils/dist.py b/src/flow_factory/utils/dist.py
index 330731502..33af7e60d 100644
--- a/src/flow_factory/utils/dist.py
+++ b/src/flow_factory/utils/dist.py
@@ -505,6 +505,7 @@ def gather_samples(
samples: Local samples on this rank.
field_names: Fields to gather. When ``'extra_kwargs'`` is included,
each key inside the dict is gathered independently and reassembled.
+ Concrete sample reconstruction fields are added automatically.
device: Target device for tensor fields in the returned samples.
Returns:
@@ -517,8 +518,10 @@ def gather_samples(
device = torch.device(device)
# Separate extra_kwargs from regular fields
- has_extra_kwargs = "extra_kwargs" in field_names
- regular_fields = sorted(f for f in field_names if f != "extra_kwargs")
+ reconstruction_fields = sample_cls.reconstruction_required_fields
+ gathered_fields = set(field_names) | set(reconstruction_fields)
+ has_extra_kwargs = "extra_kwargs" in gathered_fields
+ regular_fields = sorted(f for f in gathered_fields if f != "extra_kwargs")
extra_keys: List[str] = []
if has_extra_kwargs:
extra_keys = sorted({k for s in samples for k in s.extra_kwargs})
diff --git a/tests/trainers/test_collective_packing.py b/tests/trainers/test_collective_packing.py
index 665f0a401..75bd90bc8 100644
--- a/tests/trainers/test_collective_packing.py
+++ b/tests/trainers/test_collective_packing.py
@@ -6,7 +6,7 @@
import flow_factory.utils.dist as dist_utils
from flow_factory.advantage import AdvantageProcessor
-from flow_factory.samples import BaseSample
+from flow_factory.samples import BaseSample, MiniMaxH3Ref2VASample
from flow_factory.utils.dist import gather_aligned_floating_tensors, gather_samples
@@ -115,6 +115,22 @@ def test_gather_samples_packs_same_dtype_fields_and_preserves_other_fields():
torch.testing.assert_close(gathered[2].prompt_ids, samples[0].prompt_ids)
+def test_gather_samples_preserves_concrete_reconstruction_fields() -> None:
+ accelerator = GatherRecorder()
+ manifest = '[{"kind":"image","path":"condition.png"}]'
+ sample = MiniMaxH3Ref2VASample(
+ prompt="A reference-conditioned prompt",
+ reference_manifest=manifest,
+ )
+
+ gathered = gather_samples(accelerator, [sample], ["prompt"])
+
+ assert len(gathered) == 1
+ assert isinstance(gathered[0], MiniMaxH3Ref2VASample)
+ assert gathered[0].prompt == sample.prompt
+ assert gathered[0].reference_manifest == manifest
+
+
def test_gather_samples_keeps_large_cpu_fields_on_separate_paths(monkeypatch):
monkeypatch.setattr(dist_utils, "_CPU_PACKED_GATHER_MAX_BYTES", 1)
accelerator = GatherRecorder()
From 83ebee787725cd873d5bb29a82dbf384a67fbe6e Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 03:31:25 +0800
Subject: [PATCH 63/76] [trainer] refactor: resolve checkpoint owner before
model load
---
.agents/knowledge/topics/fix_patterns.md | 13 +-
guidance/new_model.md | 18 +--
src/flow_factory/trainers/abc.py | 57 +-------
src/flow_factory/trainers/loader.py | 7 +-
.../trainers/multirole/__init__.py | 2 +
.../trainers/multirole/backend.py | 79 +++++++++++
.../test_distributed_plan_validation.py | 124 +++++++++++++++++-
7 files changed, 228 insertions(+), 72 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 1f946ecf2..18119fcbb 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -405,12 +405,13 @@ Based on the fix type, write the fix entry to the appropriate document:
- **Root Cause**: Model-level checkpointing captured FP32 block inputs before FSDP2's forward-input
cast, while backward replay re-entered a block in `PRE_BACKWARD` state where PyTorch deliberately
skips that cast.
-- **Fix**: When full model checkpointing and FSDP2 activation checkpointing are both requested, the
- trainer now disables model-level boundaries and keeps Accelerate's backend checkpoint wrappers,
- which replay inside the fully-sharded mixed-precision boundary. Selective policies fail closed
- because backend checkpointing cannot preserve their exact selection, while FSDP1 retains its
- existing owner. Wan FSDP2 GRPO, TDM, SFT, and offline DPO plus SD3.5 and Bagel regressions verify
- the shared path.
+- **Fix**: Before loading the model, the trainer resolves one checkpoint owner. Any FSDP2 full
+ model policy is normalized to Accelerate's backend checkpoint wrappers, even when backend
+ checkpointing was initially disabled, because those wrappers replay inside the fully-sharded
+ mixed-precision boundary. Every selective FSDP2 model policy fails closed because its boundary
+ remains outside the input cast. FSDP1 retains its existing owner, and direct trainer construction
+ defensively reuses the same resolver after model realization. Wan FSDP2 GRPO, TDM, SFT, and
+ offline DPO plus SD3.5 and Bagel regressions verify the shared path.
- **Lesson**: Checkpoint placement is part of distributed precision semantics. A recompute boundary
outside a sharded module may not replay its forward hooks, so backend-aligned checkpoint wrappers
must own FSDP2 full checkpointing instead of nesting model-level boundaries around sharded blocks.
diff --git a/guidance/new_model.md b/guidance/new_model.md
index 8c86bd80c..b3b948edc 100644
--- a/guidance/new_model.md
+++ b/guidance/new_model.md
@@ -69,15 +69,15 @@ Diffusers model's `_repeated_blocks` declaration. Adapters with multiple forward
stacks should override `_gradient_checkpointing_units()` and return their blocks
in execution order.
-Checkpointing has one owner. When FSDP2 full model checkpointing and backend
-activation checkpointing are both enabled, the model policy yields ownership to
-the backend so recomputation stays inside the sharded mixed-precision boundary.
-FSDP1 keeps model-level ownership. FSDP2 rejects a selective train-level policy
-combined with backend activation checkpointing, because the backend cannot
-preserve the requested `fraction`, `every_n`, or `layers` boundary; disable
-backend activation checkpointing when using those policies. Transformers-style
-components support full checkpointing through `gradient_checkpointing_enable()`,
-but must expose the Diffusers callback API to support selective modes.
+Checkpointing has one owner. Before model loading, an FSDP2 full model policy is
+normalized to backend activation checkpointing, even when backend checkpointing
+was not explicitly enabled, so recomputation stays inside the sharded
+mixed-precision boundary. FSDP1 keeps model-level ownership. FSDP2 rejects every
+selective train-level policy because model-level `fraction`, `every_n`, or
+`layers` boundaries sit outside the FSDP2 input-cast boundary and cannot replay
+it safely. Transformers-style components support full checkpointing through
+`gradient_checkpointing_enable()`, but must expose the Diffusers callback API to
+support selective modes on compatible backends.
## Step-by-Step Implementation
diff --git a/src/flow_factory/trainers/abc.py b/src/flow_factory/trainers/abc.py
index 0ecb77551..2ceee1438 100644
--- a/src/flow_factory/trainers/abc.py
+++ b/src/flow_factory/trainers/abc.py
@@ -101,6 +101,7 @@
MULTIROLE_RUNTIME_CHILD_NAME,
MultiRoleBackendValidationMixin,
MultiRoleCheckpointingMixin,
+ configure_checkpointing_backend_plan,
configure_deepspeed_micro_batch_size,
validate_supported_distributed_plan,
)
@@ -1163,59 +1164,13 @@ def _validate_paradigm_dynamics(self) -> None:
)
def _apply_backend_checkpointing_constraints(self) -> None:
- """Select one checkpoint owner and reject the unsafe TDM-R1/FSDP1 case."""
- if self.accelerator.distributed_type != DistributedType.FSDP:
- return
- fsdp_plugin = getattr(self.accelerator.state, "fsdp_plugin", None)
- model_checkpointing = bool(
- getattr(
- self.training_args,
- "gradient_checkpointing_enabled",
- getattr(self.training_args, "enable_gradient_checkpointing", False),
- )
+ """Apply the shared owner plan to an adapter that may already be realized."""
+ disable_realized_model_checkpointing = configure_checkpointing_backend_plan(
+ self.accelerator,
+ self.training_args,
)
- fsdp_checkpointing = bool(getattr(fsdp_plugin, "activation_checkpointing", False))
- fsdp_version = getattr(fsdp_plugin, "fsdp_version", 1) or 1
- if self.training_args.trainer_type == "tdm-r1" and fsdp_version < 2:
- if not model_checkpointing and not fsdp_checkpointing:
- return
+ if disable_realized_model_checkpointing:
self.adapter.disable_gradient_checkpointing()
- self.training_args.enable_gradient_checkpointing = False
- if fsdp_plugin is not None:
- fsdp_plugin.activation_checkpointing = False
- logger.warning(
- "Disabled model and FSDP activation checkpointing for TDM-R1 on FSDP1: "
- "the surrogate objective runs reference/snapshot forwards between its live "
- "forward and backward, so FSDP1 recomputation saves a different graph. "
- "FSDP2 does not require this fallback."
- )
- return
-
- if model_checkpointing and fsdp_checkpointing:
- if fsdp_version >= 2:
- checkpoint_policy = self.training_args.enable_gradient_checkpointing
- full_checkpointing = checkpoint_policy is True or (
- getattr(checkpoint_policy, "mode", None) == "full"
- )
- if not full_checkpointing:
- raise ValueError(
- "FSDP2 activation checkpointing cannot preserve selective model "
- "checkpointing boundaries. Disable fsdp_activation_checkpointing or "
- "use train.enable_gradient_checkpointing=true/mode=full."
- )
- self.adapter.disable_gradient_checkpointing()
- self.training_args.enable_gradient_checkpointing = False
- logger.info(
- "Disabled model gradient checkpointing because FSDP2 activation "
- "checkpointing is enabled; checkpoint recomputation must stay inside "
- "the FSDP2 mixed-precision boundary."
- )
- else:
- fsdp_plugin.activation_checkpointing = False
- logger.info(
- "Disabled FSDP activation checkpointing because train-level model "
- "checkpointing is enabled; nested checkpoint boundaries duplicate recompute."
- )
def _initialization(self):
self._validate_paradigm_dynamics()
diff --git a/src/flow_factory/trainers/loader.py b/src/flow_factory/trainers/loader.py
index 2554c8c7a..673e5790e 100644
--- a/src/flow_factory/trainers/loader.py
+++ b/src/flow_factory/trainers/loader.py
@@ -32,7 +32,11 @@
from ..utils.env_utils import reconcile_config
from ..utils.logger_utils import setup_logger
from .abc import BaseTrainer
-from .multirole import validate_optimizer_backend_plan, validate_supported_distributed_plan
+from .multirole import (
+ configure_checkpointing_backend_plan,
+ validate_optimizer_backend_plan,
+ validate_supported_distributed_plan,
+)
from .registry import get_trainer_class, list_registered_trainers
logger = setup_logger(__name__)
@@ -139,6 +143,7 @@ def load_trainer(config: Arguments) -> BaseTrainer:
# rejecting it in BaseTrainer.__init__ is too late.
validate_supported_distributed_plan(accelerator)
validate_optimizer_backend_plan(accelerator, tuple(config.optimizer_args))
+ configure_checkpointing_backend_plan(accelerator, config.training_args)
set_seed(config.training_args.seed, device_specific=True)
# Reconcile config with runtime distributed state (before any consumer reads it)
diff --git a/src/flow_factory/trainers/multirole/__init__.py b/src/flow_factory/trainers/multirole/__init__.py
index 1649ff235..4cedfa522 100644
--- a/src/flow_factory/trainers/multirole/__init__.py
+++ b/src/flow_factory/trainers/multirole/__init__.py
@@ -2,6 +2,7 @@
from .backend import (
MultiRoleBackendValidationMixin,
+ configure_checkpointing_backend_plan,
configure_deepspeed_micro_batch_size,
validate_optimizer_backend_plan,
validate_supported_distributed_plan,
@@ -12,6 +13,7 @@
"MultiRoleBackendValidationMixin",
"MultiRoleCheckpointingMixin",
"MULTIROLE_RUNTIME_CHILD_NAME",
+ "configure_checkpointing_backend_plan",
"configure_deepspeed_micro_batch_size",
"validate_optimizer_backend_plan",
"validate_supported_distributed_plan",
diff --git a/src/flow_factory/trainers/multirole/backend.py b/src/flow_factory/trainers/multirole/backend.py
index 4a04813c6..8b87603d0 100644
--- a/src/flow_factory/trainers/multirole/backend.py
+++ b/src/flow_factory/trainers/multirole/backend.py
@@ -7,7 +7,11 @@
from accelerate.utils import DistributedType
from ...hparams.optimizer_args import OptimizerArguments
+from ...hparams.training_args import TrainingArguments
from ...optimizer import uses_muon
+from ...utils.logger_utils import setup_logger
+
+logger = setup_logger(__name__)
def validate_supported_distributed_plan(accelerator: Accelerator) -> None:
@@ -66,6 +70,81 @@ def validate_optimizer_backend_plan(
)
+def configure_checkpointing_backend_plan(
+ accelerator: Accelerator,
+ training_args: TrainingArguments,
+) -> bool:
+ """Select a checkpoint owner before model loading and distributed preparation.
+
+ Args:
+ accelerator: Runtime backend whose checkpointing policy is being configured.
+ training_args: Parsed algorithm arguments containing the model checkpoint policy.
+
+ Returns:
+ Whether a previously realized adapter must disable model checkpointing.
+
+ Raises:
+ ValueError: If FSDP2 is paired with a selective model checkpoint policy.
+ """
+ if accelerator.distributed_type != DistributedType.FSDP:
+ return False
+ fsdp_plugin = getattr(accelerator.state, "fsdp_plugin", None)
+ model_checkpointing = bool(
+ getattr(
+ training_args,
+ "gradient_checkpointing_enabled",
+ getattr(training_args, "enable_gradient_checkpointing", False),
+ )
+ )
+ fsdp_checkpointing = bool(getattr(fsdp_plugin, "activation_checkpointing", False))
+ fsdp_version = getattr(fsdp_plugin, "fsdp_version", 1) or 1
+
+ if training_args.trainer_type == "tdm-r1" and fsdp_version < 2:
+ if not model_checkpointing and not fsdp_checkpointing:
+ return False
+ training_args.enable_gradient_checkpointing = False
+ if fsdp_plugin is not None:
+ fsdp_plugin.activation_checkpointing = False
+ logger.warning(
+ "Disabled model and FSDP activation checkpointing for TDM-R1 on FSDP1: "
+ "the surrogate objective runs reference/snapshot forwards between its live "
+ "forward and backward, so FSDP1 recomputation saves a different graph. "
+ "FSDP2 does not require this fallback."
+ )
+ return model_checkpointing
+
+ if fsdp_version >= 2 and model_checkpointing:
+ checkpoint_policy = training_args.enable_gradient_checkpointing
+ full_checkpointing = checkpoint_policy is True or (
+ getattr(checkpoint_policy, "mode", None) == "full"
+ )
+ if not full_checkpointing:
+ raise ValueError(
+ "FSDP2 activation checkpointing cannot preserve selective model "
+ "checkpointing boundaries. Disable model checkpointing or use "
+ "train.enable_gradient_checkpointing=true/mode=full."
+ )
+ training_args.enable_gradient_checkpointing = False
+ if fsdp_plugin is None:
+ raise RuntimeError(
+ "FSDP2 full activation checkpointing requires an FSDP plugin, received None"
+ )
+ fsdp_plugin.activation_checkpointing = True
+ logger.info(
+ "Selected FSDP2 backend activation checkpointing and disabled train-level "
+ "model checkpointing so recomputation stays inside the mixed-precision boundary."
+ )
+ return True
+
+ if fsdp_version < 2 and model_checkpointing and fsdp_checkpointing:
+ fsdp_plugin.activation_checkpointing = False
+ logger.info(
+ "Disabled FSDP activation checkpointing because train-level model "
+ "checkpointing is enabled; nested checkpoint boundaries duplicate recompute."
+ )
+ return False
+
+
def configure_deepspeed_micro_batch_size(
accelerator: Accelerator, per_device_batch_size: int
) -> None:
diff --git a/tests/trainers/test_distributed_plan_validation.py b/tests/trainers/test_distributed_plan_validation.py
index 0ca9d46a7..29671761d 100644
--- a/tests/trainers/test_distributed_plan_validation.py
+++ b/tests/trainers/test_distributed_plan_validation.py
@@ -90,7 +90,11 @@ def unexpected_model_load(**kwargs: object) -> None:
def _fsdp_accelerator(fsdp_version: int) -> SimpleNamespace:
"""Build an Accelerator stub reporting the requested FSDP major version."""
accelerator = _accelerator(DistributedType.FSDP)
- accelerator.state.fsdp_plugin = SimpleNamespace(fsdp_version=fsdp_version)
+ accelerator.state.fsdp_plugin = SimpleNamespace(
+ fsdp_version=fsdp_version,
+ activation_checkpointing=False,
+ cpu_ram_efficient_loading=False,
+ )
return accelerator
@@ -150,6 +154,105 @@ def unexpected_model_load(**kwargs: object) -> None:
assert model_load_attempted is False
+@pytest.mark.parametrize("backend_checkpointing", [False, True])
+def test_loader_rejects_selective_fsdp2_checkpointing_before_loading_model(
+ monkeypatch: pytest.MonkeyPatch,
+ backend_checkpointing: bool,
+) -> None:
+ """The trainer factory must reject selective FSDP2 checkpointing before loading."""
+ accelerator = _fsdp_accelerator(fsdp_version=2)
+ accelerator.state.fsdp_plugin.activation_checkpointing = backend_checkpointing
+ model_load_attempted = False
+
+ class Adapter:
+ ddp_find_unused_parameters = False
+
+ def unexpected_model_load(**kwargs: object) -> None:
+ del kwargs
+ nonlocal model_load_attempted
+ model_load_attempted = True
+ raise AssertionError("load_model must not run for selective FSDP2 checkpointing")
+
+ config = SimpleNamespace(
+ mixed_precision="bf16",
+ optimizer_args=(),
+ model_args=SimpleNamespace(model_type="test"),
+ log_args=SimpleNamespace(save_dir="/tmp", run_name="checkpoint-plan-rejection-test"),
+ training_args=SimpleNamespace(
+ gradient_accumulation_steps=1,
+ max_grad_norm=1.0,
+ seed=42,
+ trainer_type="grpo",
+ required_trainable_roles=None,
+ enable_gradient_checkpointing=SimpleNamespace(mode="every_n"),
+ ),
+ )
+ monkeypatch.setattr(loader, "get_model_adapter_class", lambda model_type: Adapter)
+ monkeypatch.setattr(loader, "Accelerator", lambda **kwargs: accelerator)
+ monkeypatch.setattr(loader, "load_model", unexpected_model_load)
+
+ with pytest.raises(ValueError, match="cannot preserve selective"):
+ loader.load_trainer(config)
+
+ assert model_load_attempted is False
+
+
+@pytest.mark.parametrize("backend_checkpointing", [False, True])
+def test_loader_selects_fsdp2_backend_checkpointing_before_loading_model(
+ monkeypatch: pytest.MonkeyPatch,
+ backend_checkpointing: bool,
+) -> None:
+ """A full model policy must become the safe backend owner before loading."""
+ accelerator = _fsdp_accelerator(fsdp_version=2)
+ accelerator.state.fsdp_plugin.activation_checkpointing = backend_checkpointing
+ observed_plan = []
+ adapter = object()
+
+ class Adapter:
+ ddp_find_unused_parameters = False
+
+ class FakeTrainer:
+ def __init__(self, **kwargs: object) -> None:
+ self.kwargs = kwargs
+
+ config = SimpleNamespace(
+ mixed_precision="bf16",
+ optimizer_args=(),
+ model_args=SimpleNamespace(model_type="test"),
+ log_args=SimpleNamespace(save_dir="/tmp", run_name="checkpoint-plan-owner-test"),
+ training_args=SimpleNamespace(
+ gradient_accumulation_steps=1,
+ max_grad_norm=1.0,
+ seed=42,
+ trainer_type="grpo",
+ required_trainable_roles=None,
+ enable_gradient_checkpointing=True,
+ ),
+ )
+
+ def load_model_with_resolved_plan(**kwargs: object) -> object:
+ del kwargs
+ observed_plan.append(
+ (
+ config.training_args.enable_gradient_checkpointing,
+ accelerator.state.fsdp_plugin.activation_checkpointing,
+ )
+ )
+ return adapter
+
+ monkeypatch.setattr(loader, "get_trainer_class", lambda trainer_type: FakeTrainer)
+ monkeypatch.setattr(loader, "get_model_adapter_class", lambda model_type: Adapter)
+ monkeypatch.setattr(loader, "Accelerator", lambda **kwargs: accelerator)
+ monkeypatch.setattr(loader, "set_seed", lambda *args, **kwargs: None)
+ monkeypatch.setattr(loader, "reconcile_config", lambda *args, **kwargs: None)
+ monkeypatch.setattr(loader, "load_model", load_model_with_resolved_plan)
+
+ trainer = loader.load_trainer(config)
+
+ assert observed_plan == [(False, True)]
+ assert trainer.kwargs["adapter"] is adapter
+
+
@pytest.mark.parametrize("zero_stage", [1, 2])
def test_supported_deepspeed_stages_pass(zero_stage: int) -> None:
"""ZeRO-1 and ZeRO-2 are the supported DeepSpeed configurations."""
@@ -289,10 +392,15 @@ def test_tdm_r1_fsdp1_disables_incompatible_activation_checkpointing() -> None:
"checkpoint_policy",
[True, SimpleNamespace(mode="full")],
)
+@pytest.mark.parametrize("backend_checkpointing", [False, True])
def test_fsdp2_disables_model_checkpointing_and_keeps_backend_checkpointing(
checkpoint_policy: object,
+ backend_checkpointing: bool,
) -> None:
- plugin = SimpleNamespace(fsdp_version=2, activation_checkpointing=True)
+ plugin = SimpleNamespace(
+ fsdp_version=2,
+ activation_checkpointing=backend_checkpointing,
+ )
disabled = []
trainer = SimpleNamespace(
accelerator=SimpleNamespace(
@@ -337,8 +445,14 @@ def test_fsdp1_keeps_model_checkpointing_and_disables_nested_backend_checkpointi
assert plugin.activation_checkpointing is False
-def test_fsdp2_rejects_selective_model_and_backend_checkpointing() -> None:
- plugin = SimpleNamespace(fsdp_version=2, activation_checkpointing=True)
+@pytest.mark.parametrize("backend_checkpointing", [False, True])
+def test_fsdp2_rejects_selective_model_checkpointing(
+ backend_checkpointing: bool,
+) -> None:
+ plugin = SimpleNamespace(
+ fsdp_version=2,
+ activation_checkpointing=backend_checkpointing,
+ )
trainer = SimpleNamespace(
accelerator=SimpleNamespace(
distributed_type=DistributedType.FSDP,
@@ -359,7 +473,7 @@ def test_fsdp2_rejects_selective_model_and_backend_checkpointing() -> None:
BaseTrainer._apply_backend_checkpointing_constraints(trainer)
assert trainer.training_args.enable_gradient_checkpointing.mode == "every_n"
- assert plugin.activation_checkpointing is True
+ assert plugin.activation_checkpointing is backend_checkpointing
def test_fsdp2_keeps_backend_checkpointing_when_model_policy_is_disabled() -> None:
From 5349f6a937c432c097a3c8c3b0ad3d9d64e73140 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 03:42:52 +0800
Subject: [PATCH 64/76] [optimizer] fix: reject unavailable Muon before model
load
---
.../knowledge/topics/component_variants.md | 7 +--
.agents/knowledge/topics/fix_patterns.md | 18 ++++++++
guidance/workflow.md | 6 ++-
src/flow_factory/optimizer/__init__.py | 8 +++-
src/flow_factory/optimizer/loader.py | 15 +++++++
.../trainers/multirole/backend.py | 31 +++++++------
tests/hparams/test_optimizer_args.py | 20 +++++++++
.../test_distributed_plan_validation.py | 45 ++++++++++++++++++-
8 files changed, 127 insertions(+), 23 deletions(-)
diff --git a/.agents/knowledge/topics/component_variants.md b/.agents/knowledge/topics/component_variants.md
index 37e98a298..a6c1d5735 100644
--- a/.agents/knowledge/topics/component_variants.md
+++ b/.agents/knowledge/topics/component_variants.md
@@ -153,9 +153,10 @@ its children's groups as one list. An all-AdamW run still gets a plain
`torch.optim.AdamW`, unchanged.
Muon therefore gives one variant **two** parameter groups, which is why
-`OptimizationRole.optimizer_group_ids` is a tuple. Muon combined with DeepSpeed is
-rejected at startup as unverified: DDP and FSDP only read `param_groups` and call
-`step`, but DeepSpeed rebuilds its own optimizer wrapper around the object it receives.
+`OptimizationRole.optimizer_group_ids` is a tuple. It requires a PyTorch build that
+exposes `torch.optim.Muon`. DeepSpeed is rejected as unverified because it rebuilds
+its own optimizer wrapper, while FSDP1 is rejected because its flat parameters erase
+the required matrix rank; the supported distributed plans are DDP and FSDP2.
## Optimizer and backend contract
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 18119fcbb..6e1bda21b 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -709,6 +709,24 @@ Based on the fix type, write the fix entry to the appropriate document:
implementation so early rejection does not create a parallel source of truth.
- **Related Constraint**: N/A
+### Optional optimizer APIs must be validated before model loading
+- **Date**: 2026-08-31
+- **Symptom**: A Muon configuration on the supported PyTorch 2.6 baseline could pass backend
+ validation, load pretrained weights, and then fail when optimizer construction accessed the
+ unavailable `torch.optim.Muon` attribute.
+- **Root Cause**: Backend validation assumed that parsing a Muon optimizer configuration implied
+ its optional PyTorch implementation existed. Flow-Factory's minimum PyTorch version predates
+ that API, so configuration support and runtime capability are independent contracts.
+- **Fix**: One optimizer capability validator now checks the concrete `torch.optim.Muon` API.
+ The shared pre-load execution-plan validator calls it after rejecting intrinsically
+ incompatible backends, so supported DDP/FSDP2 plans fail before model loading with an
+ actionable upgrade message. Direct optimizer construction and the defensive pre-optimizer
+ plan check reuse the same rule.
+- **Lesson**: Optional APIs gated by dependency versions belong in the same early execution-plan
+ validation as backend compatibility. Detect the capability itself instead of trusting a version
+ string, while keeping a late defensive call at the construction boundary.
+- **Related Constraint**: N/A
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/guidance/workflow.md b/guidance/workflow.md
index 491eb3d14..a52e24c3d 100644
--- a/guidance/workflow.md
+++ b/guidance/workflow.md
@@ -668,8 +668,10 @@ so a Muon variant is driven by two algorithms at once: Muon for its matrices and
AdamW for its biases, normalization scales and embeddings, which the `fallback_`
fields configure. `optimizer/loader.py` wraps that pair in a `CompositeOptimizer` so
the framework still prepares exactly one root. An all-AdamW run gets a plain
-`torch.optim.AdamW`, unchanged. Muon combined with DeepSpeed is refused at startup as
-unverified; use DDP or FSDP.
+`torch.optim.AdamW`, unchanged. Muon requires a PyTorch build that exposes
+`torch.optim.Muon` (2.10 or newer in supported environments). Muon combined with
+DeepSpeed is refused at startup as unverified, and FSDP1 flattens matrices into
+incompatible parameters; use DDP or FSDP2.
### Key Points
diff --git a/src/flow_factory/optimizer/__init__.py b/src/flow_factory/optimizer/__init__.py
index a0b70131e..f207f28a4 100644
--- a/src/flow_factory/optimizer/__init__.py
+++ b/src/flow_factory/optimizer/__init__.py
@@ -15,11 +15,17 @@
"""Build the single optimizer root from per-variant configurations."""
from .composite import CompositeOptimizer
-from .loader import build_optimizer, split_muon_parameters, uses_muon
+from .loader import (
+ build_optimizer,
+ split_muon_parameters,
+ uses_muon,
+ validate_muon_available,
+)
__all__ = [
"CompositeOptimizer",
"build_optimizer",
"split_muon_parameters",
"uses_muon",
+ "validate_muon_available",
]
diff --git a/src/flow_factory/optimizer/loader.py b/src/flow_factory/optimizer/loader.py
index aa56045ae..eb3232890 100644
--- a/src/flow_factory/optimizer/loader.py
+++ b/src/flow_factory/optimizer/loader.py
@@ -30,6 +30,20 @@
MUON_MINIMUM_DIMENSIONS = 2
+def validate_muon_available() -> None:
+ """Require the optional PyTorch Muon implementation.
+
+ Raises:
+ ValueError: If this PyTorch build does not expose ``torch.optim.Muon``.
+ """
+ if not hasattr(torch.optim, "Muon"):
+ raise ValueError(
+ f"torch.optim.Muon is unavailable in PyTorch {torch.__version__}. Install a "
+ "PyTorch build that provides Muon (2.10 or newer in supported environments), "
+ "or select the adamw optimizer."
+ )
+
+
def split_muon_parameters(
parameters: Sequence[torch.nn.Parameter],
) -> Tuple[List[torch.nn.Parameter], List[torch.nn.Parameter]]:
@@ -136,6 +150,7 @@ def build_optimizer(
if not muon_groups:
return torch.optim.AdamW(adamw_groups)
+ validate_muon_available()
children: List[torch.optim.Optimizer] = [torch.optim.Muon(muon_groups)]
if adamw_groups:
children.append(torch.optim.AdamW(adamw_groups))
diff --git a/src/flow_factory/trainers/multirole/backend.py b/src/flow_factory/trainers/multirole/backend.py
index 8b87603d0..e877d9956 100644
--- a/src/flow_factory/trainers/multirole/backend.py
+++ b/src/flow_factory/trainers/multirole/backend.py
@@ -8,7 +8,7 @@
from ...hparams.optimizer_args import OptimizerArguments
from ...hparams.training_args import TrainingArguments
-from ...optimizer import uses_muon
+from ...optimizer import uses_muon, validate_muon_available
from ...utils.logger_utils import setup_logger
logger = setup_logger(__name__)
@@ -43,7 +43,7 @@ def validate_optimizer_backend_plan(
None.
Raises:
- ValueError: If Muon is paired with DeepSpeed or FSDP1.
+ ValueError: If Muon is unavailable or paired with DeepSpeed or FSDP1.
"""
if not uses_muon(optimizer_args):
return
@@ -54,20 +54,19 @@ def validate_optimizer_backend_plan(
"DeepSpeed rebuilds its own optimizer wrapper around the object it "
"receives. Use DDP or FSDP2 with Muon, or select the adamw optimizer."
)
- if accelerator.distributed_type != DistributedType.FSDP:
- return
- fsdp_plugin = getattr(accelerator.state, "fsdp_plugin", None)
- fsdp_version = getattr(fsdp_plugin, "fsdp_version", 1) if fsdp_plugin else 1
- if fsdp_version >= 2:
- return
- raise ValueError(
- "Muon with FSDP1 does not work: FSDP1 flattens each wrapped unit into a "
- "1D FlatParameter, so Muon is constructed over matrices and then receives "
- "a 1D gradient, failing with 'Param gradient must be a 2D matrix' at the "
- "first optimizer step. Set `fsdp_version: 2` in the accelerate config "
- "(config/accelerate_configs/fsdp2.yaml), use DDP, or select the adamw "
- "optimizer."
- )
+ if accelerator.distributed_type == DistributedType.FSDP:
+ fsdp_plugin = getattr(accelerator.state, "fsdp_plugin", None)
+ fsdp_version = getattr(fsdp_plugin, "fsdp_version", 1) if fsdp_plugin else 1
+ if fsdp_version < 2:
+ raise ValueError(
+ "Muon with FSDP1 does not work: FSDP1 flattens each wrapped unit into a "
+ "1D FlatParameter, so Muon is constructed over matrices and then receives "
+ "a 1D gradient, failing with 'Param gradient must be a 2D matrix' at the "
+ "first optimizer step. Set `fsdp_version: 2` in the accelerate config "
+ "(config/accelerate_configs/fsdp2.yaml), use DDP, or select the adamw "
+ "optimizer."
+ )
+ validate_muon_available()
def configure_checkpointing_backend_plan(
diff --git a/tests/hparams/test_optimizer_args.py b/tests/hparams/test_optimizer_args.py
index 3e61c4941..32e775382 100644
--- a/tests/hparams/test_optimizer_args.py
+++ b/tests/hparams/test_optimizer_args.py
@@ -22,6 +22,11 @@
)
from flow_factory.optimizer import CompositeOptimizer, build_optimizer, split_muon_parameters
+requires_muon_api = pytest.mark.skipif(
+ not hasattr(torch.optim, "Muon"),
+ reason="functional Muon tests require torch.optim.Muon",
+)
+
def test_the_optimizer_key_selects_the_arguments_subclass() -> None:
"""AdamW and Muon hyperparameters live on separate classes, chosen by one key."""
@@ -66,6 +71,7 @@ def test_all_adamw_configurations_build_one_plain_adamw() -> None:
assert [group["role_name"] for group in optimizer.param_groups] == ["base"]
+@requires_muon_api
def test_muon_splits_a_variant_into_its_matrices_and_the_adamw_remainder() -> None:
"""torch.optim.Muon rejects non-matrix parameters, so the rest needs AdamW."""
matrix = torch.nn.Parameter(torch.randn(4, 3))
@@ -84,6 +90,7 @@ def test_muon_splits_a_variant_into_its_matrices_and_the_adamw_remainder() -> No
assert [group["role_name"] for group in optimizer.param_groups] == ["base", "base"]
+@requires_muon_api
def test_a_composite_steps_every_child_and_round_trips_its_state() -> None:
"""The framework prepares one optimizer, so the composite must behave like one."""
matrix = torch.nn.Parameter(torch.randn(4, 3))
@@ -111,6 +118,7 @@ def test_a_composite_steps_every_child_and_round_trips_its_state() -> None:
assert matrix.grad is None
+@requires_muon_api
def test_mixed_optimizers_across_variants_share_one_root() -> None:
"""One variant on Muon and another on AdamW still yields a single optimizer."""
generator = torch.nn.Parameter(torch.randn(4, 3))
@@ -137,6 +145,18 @@ def test_muon_requires_at_least_one_matrix_parameter() -> None:
)
+def test_muon_requires_the_optional_pytorch_api(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Direct optimizer construction must share the trainer's capability error."""
+ monkeypatch.delattr(torch.optim, "Muon", raising=False)
+
+ with pytest.raises(ValueError, match="torch.optim.Muon is unavailable"):
+ build_optimizer(
+ (MuonOptimizerArguments(name="base"),),
+ {"base": [torch.nn.Parameter(torch.randn(4, 3))]},
+ )
+
+
+@requires_muon_api
def test_a_composite_refuses_to_have_its_groups_reassigned() -> None:
"""The group list is a view; reassigning it would detach the children silently."""
optimizer = build_optimizer(
diff --git a/tests/trainers/test_distributed_plan_validation.py b/tests/trainers/test_distributed_plan_validation.py
index 29671761d..5e0e26fe0 100644
--- a/tests/trainers/test_distributed_plan_validation.py
+++ b/tests/trainers/test_distributed_plan_validation.py
@@ -154,6 +154,46 @@ def unexpected_model_load(**kwargs: object) -> None:
assert model_load_attempted is False
+def test_loader_rejects_unavailable_muon_before_loading_model(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A supported backend still requires the optimizer API before model loading."""
+ accelerator = _accelerator(DistributedType.MULTI_GPU)
+ model_load_attempted = False
+
+ class Adapter:
+ ddp_find_unused_parameters = False
+
+ def unexpected_model_load(**kwargs: object) -> None:
+ del kwargs
+ nonlocal model_load_attempted
+ model_load_attempted = True
+ raise AssertionError("load_model must not run without torch.optim.Muon")
+
+ config = SimpleNamespace(
+ mixed_precision="bf16",
+ optimizer_args=(MuonOptimizerArguments(name="base"),),
+ model_args=SimpleNamespace(model_type="test"),
+ log_args=SimpleNamespace(save_dir="/tmp", run_name="muon-api-rejection-test"),
+ training_args=SimpleNamespace(
+ gradient_accumulation_steps=1,
+ max_grad_norm=1.0,
+ seed=42,
+ trainer_type="grpo",
+ required_trainable_roles=None,
+ ),
+ )
+ monkeypatch.delattr(torch.optim, "Muon", raising=False)
+ monkeypatch.setattr(loader, "get_model_adapter_class", lambda model_type: Adapter)
+ monkeypatch.setattr(loader, "Accelerator", lambda **kwargs: accelerator)
+ monkeypatch.setattr(loader, "load_model", unexpected_model_load)
+
+ with pytest.raises(ValueError, match="torch.optim.Muon is unavailable"):
+ loader.load_trainer(config)
+
+ assert model_load_attempted is False
+
+
@pytest.mark.parametrize("backend_checkpointing", [False, True])
def test_loader_rejects_selective_fsdp2_checkpointing_before_loading_model(
monkeypatch: pytest.MonkeyPatch,
@@ -284,7 +324,9 @@ def test_deepspeed_micro_batch_size_is_set_for_custom_train_loader() -> None:
)
-def test_muon_with_deepspeed_is_rejected_as_unverified() -> None:
+def test_muon_with_deepspeed_is_rejected_as_unverified(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
"""Muon runs inside a composite; DeepSpeed rebuilds its own optimizer wrapper."""
trainer = SimpleNamespace(accelerator=_accelerator(DistributedType.DEEPSPEED, zero_stage=2))
@@ -294,6 +336,7 @@ def test_muon_with_deepspeed_is_rejected_as_unverified() -> None:
# AdamW is unaffected, and Muon is fine on the backends that preserve parameter rank.
BaseTrainer._validate_optimizer_backend(trainer, (AdamWOptimizerArguments(name="base"),))
fsdp2_trainer = SimpleNamespace(accelerator=_fsdp_accelerator(fsdp_version=2))
+ monkeypatch.setattr(torch.optim, "Muon", object(), raising=False)
BaseTrainer._validate_optimizer_backend(fsdp2_trainer, (MuonOptimizerArguments(name="base"),))
From 1846911e01bf46e745815de5e2ac1a7cd3091a84 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 07:27:55 +0800
Subject: [PATCH 65/76] [opd] fix: keep monitoring rewards eval-only
---
.agents/knowledge/topics/fix_patterns.md | 13 +++++++++
.../opd/lora/sd3_5/DiffusionOPD_aligned.yaml | 28 +------------------
.../opd/lora/sd3_5/geneval_pickscore_ocr.yaml | 27 +-----------------
.../sd3_5/geneval_pickscore_ocr_x0_norm.yaml | 27 +-----------------
guidance/algorithms.md | 4 +--
.../trainers/test_distillation_evaluation.py | 12 ++++++--
6 files changed, 28 insertions(+), 83 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 6e1bda21b..b5dd4c067 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -727,6 +727,19 @@ Based on the fix type, write the fix entry to the appropriate document:
string, while keeping a late defensive call at the construction boundary.
- **Related Constraint**: N/A
+### No-feedback algorithms must keep monitoring rewards eval-only
+- **Date**: 2026-08-31
+- **Symptom**: Every shipped DiffusionOPD example failed argument loading even though its reward
+ comments described monitoring rather than a training signal.
+- **Root Cause**: The examples duplicated evaluation rewards under top-level `rewards`, but the
+ algorithm declares a no-feedback execution contract that rejects every training reward.
+- **Fix**: The DiffusionOPD examples now keep monitoring models only under `eval_rewards`; the
+ algorithm guide states that evaluation scores never enter the distillation loss, and the shared
+ distillation contract tests cover DiffusionOPD alongside DMD2 and TDM.
+- **Lesson**: Monitoring intent does not change configuration semantics. A no-feedback algorithm
+ must express quality metrics through the evaluation-only reward surface.
+- **Related Constraint**: #7
+
## Cross-refs
- `constraints.md` (archival target for constraint violations)
diff --git a/examples/opd/lora/sd3_5/DiffusionOPD_aligned.yaml b/examples/opd/lora/sd3_5/DiffusionOPD_aligned.yaml
index beb1ccd91..de986bdd8 100644
--- a/examples/opd/lora/sd3_5/DiffusionOPD_aligned.yaml
+++ b/examples/opd/lora/sd3_5/DiffusionOPD_aligned.yaml
@@ -151,33 +151,7 @@ eval:
eval_freq: 30 # Run evaluation every N epochs (0 to disable) — upstream mopd eval_freq
seed: 42 # Eval random seed
-# Reward Model Configuration (monitoring only — NOT used by the distillation loss)
-# Routing via `applicable_datasets`: geneval -> geneval, ocr -> ocr,
-# pick_score -> all three datasets (covers the `pickscore` dataset and provides
-# a general-quality signal on the others).
-rewards:
- - name: "geneval" # Reward identifier (appears in log keys)
- reward_model: "GenEval" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: float32 # Precision for reward model inference
- applicable_datasets: ["geneval"] # Only fires on geneval source batches
-
- - name: "ocr" # Reward identifier (appears in log keys)
- reward_model: "OCR" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: bfloat16 # Precision for reward model inference
- applicable_datasets: ["ocr"] # Only fires on ocr source batches
-
- - name: "pick_score" # General image-quality reward (covers the pickscore dataset)
- reward_model: "PickScore" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: bfloat16 # Precision for reward model inference
- # applicable_datasets omitted -> applies to ALL training datasets (pickscore, ocr, geneval)
-
-# Eval Reward Models (same routing as training rewards)
+# Evaluation-only Reward Models (used by evaluate(); never by the distillation loss)
eval_rewards:
- name: "geneval" # Eval reward identifier
reward_model: "GenEval" # Reward model class (from registry)
diff --git a/examples/opd/lora/sd3_5/geneval_pickscore_ocr.yaml b/examples/opd/lora/sd3_5/geneval_pickscore_ocr.yaml
index 5e2e85393..66874f182 100644
--- a/examples/opd/lora/sd3_5/geneval_pickscore_ocr.yaml
+++ b/examples/opd/lora/sd3_5/geneval_pickscore_ocr.yaml
@@ -165,32 +165,7 @@ eval:
eval_freq: 10 # Run evaluation every N epochs (0 to disable)
seed: 42 # Eval random seed
-# Reward Model Configuration (monitoring only — NOT used by the distillation loss)
-# Routing via `applicable_datasets`: geneval -> geneval, ocr -> ocr,
-# pick_score -> all three sources (applicable_datasets omitted = applies to all).
-rewards:
- - name: "geneval" # Reward identifier (appears in log keys)
- reward_model: "GenEval" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: float32 # Precision for reward model inference
- applicable_datasets: ["geneval"] # Only fires on geneval source batches
-
- - name: "ocr" # Reward identifier (appears in log keys)
- reward_model: "OCR" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: bfloat16 # Precision for reward model inference
- applicable_datasets: ["ocr"] # Only fires on ocr source batches
-
- - name: "pick_score" # Reward identifier (appears in log keys)
- reward_model: "PickScore" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: bfloat16 # Precision for reward model inference
- # applicable_datasets omitted -> applies to ALL training sources (geneval, pickscore, ocr)
-
-# Eval Reward Models (same routing as training rewards)
+# Evaluation-only Reward Models (used by evaluate(); never by the distillation loss)
eval_rewards:
- name: "geneval" # Eval reward identifier
reward_model: "GenEval" # Reward model class (from registry)
diff --git a/examples/opd/lora/sd3_5/geneval_pickscore_ocr_x0_norm.yaml b/examples/opd/lora/sd3_5/geneval_pickscore_ocr_x0_norm.yaml
index 8830bed76..245841414 100644
--- a/examples/opd/lora/sd3_5/geneval_pickscore_ocr_x0_norm.yaml
+++ b/examples/opd/lora/sd3_5/geneval_pickscore_ocr_x0_norm.yaml
@@ -165,32 +165,7 @@ eval:
eval_freq: 10 # Run evaluation every N epochs (0 to disable)
seed: 42 # Eval random seed
-# Reward Model Configuration (monitoring only — NOT used by the distillation loss)
-# Routing via `applicable_datasets`: geneval -> geneval, ocr -> ocr,
-# pick_score -> all three sources (applicable_datasets omitted = applies to all).
-rewards:
- - name: "geneval" # Reward identifier (appears in log keys)
- reward_model: "GenEval" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: float32 # Precision for reward model inference
- applicable_datasets: ["geneval"] # Only fires on geneval source batches
-
- - name: "ocr" # Reward identifier (appears in log keys)
- reward_model: "OCR" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: bfloat16 # Precision for reward model inference
- applicable_datasets: ["ocr"] # Only fires on ocr source batches
-
- - name: "pick_score" # Reward identifier (appears in log keys)
- reward_model: "PickScore" # Reward model class (from registry)
- batch_size: 32 # Inference batch size for this reward model
- device: "cuda" # Device to run reward model on
- dtype: bfloat16 # Precision for reward model inference
- # applicable_datasets omitted -> applies to ALL training sources (geneval, pickscore, ocr)
-
-# Eval Reward Models (same routing as training rewards)
+# Evaluation-only Reward Models (used by evaluate(); never by the distillation loss)
eval_rewards:
- name: "geneval" # Eval reward identifier
reward_model: "GenEval" # Reward model class (from registry)
diff --git a/guidance/algorithms.md b/guidance/algorithms.md
index 67c2bca26..7f90b6124 100644
--- a/guidance/algorithms.md
+++ b/guidance/algorithms.md
@@ -762,7 +762,7 @@ The dynamics support matrix is:
| `v` | Yes | No | None |
| `x0` | Yes | No | None |
-`v` and `x0` fail fast under non-ODE dynamics because the target conversion assumes the ODE relation `mu = x_t + v * dt`. The `xt` target remains valid for Flow-SDE, Dance-SDE, and CPS; after optional self-normalization it is divided by the scheduler transition variance. No target uses the historical `0.5` multiplier. Rewards are used **only** for periodic eval monitoring (`evaluate()`), never in the distillation loss.
+`v` and `x0` fail fast under non-ODE dynamics because the target conversion assumes the ODE relation `mu = x_t + v * dt`. The `xt` target remains valid for Flow-SDE, Dance-SDE, and CPS; after optional self-normalization it is divided by the scheduler transition variance. No target uses the historical `0.5` multiplier. DiffusionOPD rejects training `rewards` because its execution contract has no feedback stage. Configure periodic monitoring only under `eval_rewards`; those scores are used by `evaluate()` and never enter the distillation loss.
### How it works (2-pass per epoch)
@@ -806,7 +806,7 @@ scheduler:
noise_level: 0.0
```
-Each teacher's `applicable_datasets` must reference declared `data.datasets[*].name` entries (validated at config load). The config schema allows several teachers to share a dataset for a future multi-teacher/ensemble trainer, but the current `DiffusionOPDTrainer` requires exactly one teacher per dataset and raises otherwise. See [`examples/opd/lora/sd3_5/`](../examples/opd/lora/sd3_5/) for two complete configs (`DiffusionOPD_aligned.yaml` to reproduce official results).
+Each teacher's `applicable_datasets` must reference declared `data.datasets[*].name` entries (validated at config load). The config schema allows several teachers to share a dataset for a future multi-teacher/ensemble trainer, but the current `DiffusionOPDTrainer` requires exactly one teacher per dataset and raises otherwise. See [`examples/opd/lora/sd3_5/`](../examples/opd/lora/sd3_5/) for three complete configs; `DiffusionOPD_aligned.yaml` reproduces the official setup.
## References
diff --git a/tests/trainers/test_distillation_evaluation.py b/tests/trainers/test_distillation_evaluation.py
index 98fd94141..728a04d90 100644
--- a/tests/trainers/test_distillation_evaluation.py
+++ b/tests/trainers/test_distillation_evaluation.py
@@ -114,11 +114,19 @@ def _distillation_config(trainer_type: str, **sections: Any) -> Arguments:
}
if trainer_type == "tdm-r1":
config["train"]["group_size"] = 2
+ if trainer_type == "diffusion-opd":
+ config["train"]["teachers"] = [
+ {
+ "name": "teacher",
+ "path": "teacher/lora",
+ "applicable_datasets": ["prompts"],
+ }
+ ]
config.update(sections)
return Arguments.from_dict(config)
-@pytest.mark.parametrize("trainer_type", ["dmd2", "tdm"])
+@pytest.mark.parametrize("trainer_type", ["dmd2", "tdm", "diffusion-opd"])
def test_reward_free_distillation_accepts_eval_rewards_only(trainer_type: str) -> None:
"""Quality monitoring is an evaluation concern; the loss stays reward-free."""
config = _distillation_config(
@@ -131,7 +139,7 @@ def test_reward_free_distillation_accepts_eval_rewards_only(trainer_type: str) -
assert config.eval_reward_args.get_by_name("quality").applicable_datasets == ["bench"]
-@pytest.mark.parametrize("trainer_type", ["dmd2", "tdm"])
+@pytest.mark.parametrize("trainer_type", ["dmd2", "tdm", "diffusion-opd"])
def test_reward_free_distillation_still_rejects_training_rewards(trainer_type: str) -> None:
"""An eval-only reward must not become a training signal by accident."""
with pytest.raises(ValueError, match="rewards"):
From cfecd731d3896b1696a77cda1916fbf199e5ced9 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 07:38:52 +0800
Subject: [PATCH 66/76] [docs] fix: align runtime docstrings with contracts
---
src/flow_factory/data_utils/dataset.py | 15 +++--
src/flow_factory/loading/backend.py | 13 +++-
src/flow_factory/loading/coordinator.py | 10 ++-
.../models/bagel/modeling/bagel/bagel.py | 66 +++++++++++++++++++
.../bagel/modeling/bagel/qwen2_navit.py | 27 ++++++++
.../models/minimax_h3/_chunking.py | 59 +++++++++++++++--
.../models/minimax_h3/adapters.py | 17 ++++-
.../models/minimax_h3/dependency.py | 2 +-
.../models/minimax_h3/workflow.py | 14 +++-
src/flow_factory/models/precision.py | 42 +++++++++++-
src/flow_factory/models/runtime/classic.py | 10 ++-
src/flow_factory/models/wan/_conditioning.py | 25 ++++++-
src/flow_factory/models/wan/wan2_i2v.py | 5 +-
src/flow_factory/optimizer/loader.py | 4 +-
src/flow_factory/samples/samples.py | 4 +-
src/flow_factory/trainers/loader.py | 25 +++----
.../trainers/multirole/backend.py | 6 +-
src/flow_factory/utils/dist.py | 8 +--
18 files changed, 306 insertions(+), 46 deletions(-)
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index cf7e09653..d24a1dd5a 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -551,12 +551,14 @@ def _preprocess_batch(
2. Load and prepare image inputs
3. Load and prepare video inputs
4. Load and prepare audio inputs
- 5. Call preprocess function
- 6. Move result tensors to CPU for caching
- 7. Pack non-preprocessed columns into ``metadata``
+ 5. Load ordered references and forward their canonical manifest sidecars
+ 6. Call preprocess function
+ 7. Move result tensors to CPU for caching
+ 8. Pack non-preprocessed columns into ``metadata``
Args:
batch: Dictionary with batch data.
+ indices: Source row indices aligned with batch rows, used in validation diagnostics.
image_dir: Directory containing images (``None`` skips image loading).
Per-sample paths are loaded as PIL Images and kept as a
``List[Image]``; the column-level ``images`` field is therefore
@@ -697,6 +699,7 @@ def _preprocess_batch(
audio_args["audios"].append(audios)
batch["audios"].append(audios)
+ # 5. Load ordered references and retain their canonical reconstruction sidecars.
reference_args: Dict[str, Any] = {}
if self._uses_ordered_references:
raw_references = batch.pop("references")
@@ -732,7 +735,7 @@ def _preprocess_batch(
if column in batch
}
- # 5. Call preprocess function with filtered kwargs
+ # 6. Call preprocess function with filtered kwargs
input_args = {
**prompt_args,
**image_args,
@@ -751,7 +754,7 @@ def _preprocess_batch(
f"{sorted(passthrough_collisions)!r}"
)
- # 6. Process results - move tensors to CPU for caching.
+ # 7. Process results - move tensors to CPU for caching.
# Image-valued adapter outputs (declared via `python_format_columns`)
# are stored as per-sample List[PIL] so HF serializes them via the Image
# feature; ragged image tensors (variable size/count, e.g. multi-ref I2I)
@@ -783,7 +786,7 @@ def _preprocess_batch(
# Case C: Other types (None, int, etc)
final_res[k] = v
- # 7. Prepare final results
+ # 8. Prepare final results
batch_dict = {**batch, **final_res}
if self._uses_ordered_references:
_validate_arrow_safe_ordered_result(batch_dict, len(batch["prompt"]))
diff --git a/src/flow_factory/loading/backend.py b/src/flow_factory/loading/backend.py
index 388ade55d..23ccd88fa 100644
--- a/src/flow_factory/loading/backend.py
+++ b/src/flow_factory/loading/backend.py
@@ -130,7 +130,18 @@ def load_scope(self, role: ComponentRole) -> Iterator[None]:
os.environ["FSDP_CPU_RAM_EFFICIENT_LOADING"] = previous
def prepare(self, *objects: Any) -> Any:
- """Prepare roots and apply adapter-requested FSDP2 communication policy."""
+ """Apply adapter-owned FSDP2 wrap, checkpoint, replay, and communication policies.
+
+ Args:
+ *objects: Model roots and related objects forwarded to ``Accelerator.prepare``.
+
+ Returns:
+ The prepared object or tuple returned by ``Accelerator.prepare``.
+
+ Raises:
+ TypeError: If an adapter-requested FSDP2 policy cannot be installed on the supplied
+ or prepared roots.
+ """
plugin = self.accelerator.state.fsdp_plugin
use_in_forward_checkpointing = (
(getattr(plugin, "fsdp_version", 1) or 1) >= 2
diff --git a/src/flow_factory/loading/coordinator.py b/src/flow_factory/loading/coordinator.py
index bfec60399..9da7b2e5c 100644
--- a/src/flow_factory/loading/coordinator.py
+++ b/src/flow_factory/loading/coordinator.py
@@ -96,7 +96,15 @@ def load_components(
*,
device: Any,
) -> None:
- """Load replicas and target-owned auxiliary remainders without moving targets."""
+ """Load replicas and target-owned auxiliary remainders without moving targets.
+
+ Args:
+ components: Logical components requested by the adapter lifecycle call.
+ device: Destination device forwarded to component materialization.
+
+ Returns:
+ None. Only replicas observed as materialized are finalized through the backend.
+ """
requested = self.adapter._resolve_component_names(components)
replicated = [
name
diff --git a/src/flow_factory/models/bagel/modeling/bagel/bagel.py b/src/flow_factory/models/bagel/modeling/bagel/bagel.py
index 06286fa99..dcffb21fb 100644
--- a/src/flow_factory/models/bagel/modeling/bagel/bagel.py
+++ b/src/flow_factory/models/bagel/modeling/bagel/bagel.py
@@ -301,6 +301,24 @@ def forward_cache_update_text(
key_values_lens: torch.IntTensor,
language_model_forward: Optional[Callable[..., Any]] = None,
):
+ """Append packed text tokens to the language-model cache.
+
+ Args:
+ past_key_values: Existing packed key/value cache.
+ packed_text_ids: Raw text token IDs to embed inside the routed model forward.
+ packed_text_position_ids: Packed position IDs for the appended text.
+ text_token_lens: Per-sample text query lengths.
+ packed_text_indexes: Packed query destination indices.
+ packed_key_value_indexes: Packed indices of the existing cache entries.
+ key_values_lens: Per-sample existing cache lengths.
+ language_model_forward: Optional outer language-model callable. Flow-Factory injects
+ the prepared/routed transformer so embedding, decoder, and final normalization
+ stay under the sharded root's forward hooks; standalone Bagel defaults to
+ ``self.language_model``.
+
+ Returns:
+ The updated packed key/value cache.
+ """
# Flow-Factory injects its prepared component route here. Standalone Bagel
# keeps the original physical language-model call as the default.
if language_model_forward is None:
@@ -419,6 +437,29 @@ def forward_cache_update_vit(
key_values_lens: torch.IntTensor,
language_model_forward: Optional[Callable[..., Any]] = None,
):
+ """Encode packed vision tokens and append them to the language-model cache.
+
+ Args:
+ past_key_values: Existing packed key/value cache.
+ packed_text_ids: Raw boundary-token IDs inserted into the packed query.
+ packed_text_indexes: Destination indices for the boundary-token embeddings.
+ packed_vit_tokens: Flattened image inputs consumed by the vision encoder.
+ packed_vit_token_indexes: Destination indices for encoded vision tokens.
+ packed_vit_position_ids: Packed vision position IDs.
+ vit_token_seqlens: Per-image vision token lengths.
+ packed_position_ids: Packed language-model query position IDs.
+ packed_seqlens: Per-sample query lengths.
+ packed_indexes: Packed query indices.
+ packed_key_value_indexes: Packed indices of the existing cache entries.
+ key_values_lens: Per-sample existing cache lengths.
+ language_model_forward: Optional outer language-model callable. Flow-Factory injects
+ the prepared/routed transformer so embedding, decoder, and final normalization
+ stay under the sharded root's forward hooks; standalone Bagel defaults to
+ ``self.language_model``.
+
+ Returns:
+ The updated packed key/value cache.
+ """
if language_model_forward is None:
language_model_forward = self.language_model
@@ -570,6 +611,31 @@ def forward_cache_update_vae(
packed_key_value_indexes: torch.Tensor,
language_model_forward: Optional[Callable[..., Any]] = None,
):
+ """Encode packed image latents and append them to the language-model cache.
+
+ Args:
+ vae_model: VAE used to encode the padded image batch.
+ past_key_values: Existing packed key/value cache.
+ padded_images: Padded image tensors for the active reference round.
+ patchified_vae_latent_shapes: Per-image patch-grid heights and widths.
+ packed_vae_position_ids: Packed latent position IDs.
+ packed_timesteps: Timesteps used by the latent time embedder.
+ packed_vae_token_indexes: Destination indices for encoded latent tokens.
+ packed_text_ids: Raw boundary-token IDs inserted into the packed query.
+ packed_text_indexes: Destination indices for the boundary-token embeddings.
+ packed_position_ids: Packed language-model query position IDs.
+ packed_seqlens: Per-sample query lengths.
+ packed_indexes: Packed query indices.
+ key_values_lens: Per-sample existing cache lengths.
+ packed_key_value_indexes: Packed indices of the existing cache entries.
+ language_model_forward: Optional outer language-model callable. Flow-Factory injects
+ the prepared/routed transformer so embedding, decoder, and final normalization
+ stay under the sharded root's forward hooks; standalone Bagel defaults to
+ ``self.language_model``.
+
+ Returns:
+ The updated packed key/value cache.
+ """
if language_model_forward is None:
language_model_forward = self.language_model
diff --git a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
index 9d0ad9328..186966970 100644
--- a/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
+++ b/src/flow_factory/models/bagel/modeling/bagel/qwen2_navit.py
@@ -1326,6 +1326,33 @@ def forward_inference(
packed_text_indexes=None,
packed_text_ids: Optional[torch.LongTensor] = None,
) -> BaseNavitOutputWithPast:
+ """Run one packed language-model forward from embeddings, token IDs, or both.
+
+ Args:
+ packed_query_sequence: Existing packed auxiliary sequence, or None for a text-only
+ cache update.
+ query_lens: Per-sample query lengths.
+ packed_query_position_ids: Packed query position IDs.
+ packed_query_indexes: Packed query indices.
+ past_key_values: Optional existing packed key/value cache.
+ key_values_lens: Per-sample existing cache lengths.
+ packed_key_value_indexes: Packed indices of existing cache entries.
+ update_past_key_values: Whether to append the query to the cache.
+ is_causal: Whether the query uses causal attention.
+ mode: Bagel language-model routing mode.
+ packed_vae_token_indexes: Optional positions of generative latent tokens.
+ packed_text_indexes: Destination indices when inserting raw text embeddings into an
+ existing packed sequence.
+ packed_text_ids: Raw token IDs to embed inside this prepared-root forward. Required
+ when ``packed_query_sequence`` is None.
+
+ Returns:
+ Packed model output with the updated cache when requested.
+
+ Raises:
+ ValueError: If neither input source is supplied, destination indices are missing, or
+ token and destination counts differ.
+ """
# Keep token embedding inside the outer language-model forward. Distributed
# wrappers attach their unshard hooks to this boundary, so callers must not
diff --git a/src/flow_factory/models/minimax_h3/_chunking.py b/src/flow_factory/models/minimax_h3/_chunking.py
index 3f346bd5c..efb16355c 100644
--- a/src/flow_factory/models/minimax_h3/_chunking.py
+++ b/src/flow_factory/models/minimax_h3/_chunking.py
@@ -325,8 +325,16 @@ def install_h3_feed_forward_chunking(
distributed wrapping. The mutation is idempotent and preserves every parameter
object.
+ Args:
+ transformer: Materialized H3 transformer containing both repeated block stacks.
+ max_tokens: Positive maximum token count processed by one feed-forward invocation.
+
Returns:
Number of feed-forward layers configured across both stacks.
+
+ Raises:
+ TypeError: If the materialized upstream feed-forward structure or hooks are unsupported.
+ ValueError: If ``max_tokens`` is invalid or conflicts with an earlier installation.
"""
max_tokens = _positive_int(max_tokens, "max_tokens")
blocks = tuple(_h3_repeated_blocks(transformer))
@@ -382,8 +390,18 @@ def install_h3_in_forward_block_checkpointing(transformer: nn.Module) -> int:
inputs before FSDP2 casts them for mixed-precision compute. Accelerate's generic
FSDP2 policy checkpoints each direct child separately. This instance-local
forward delegates the complete original block body to one non-reentrant
- checkpoint after the block's FSDP pre-forward hook has already run. Its saved
- BF16 block inputs live in pinned CPU memory until their backward replay.
+ checkpoint after the block's FSDP pre-forward hook has already run. Saved tensor inputs are
+ offloaded to CPU until backward replay, using pinned host memory for CUDA inputs. In the
+ intended BF16 FSDP2 path these are the post-cast BF16 block inputs.
+
+ Args:
+ transformer: Materialized H3 transformer containing both repeated block stacks.
+
+ Returns:
+ Number of repeated H3 blocks configured across both stacks.
+
+ Raises:
+ TypeError: If a block has an unsupported or conflicting forward installation.
"""
configured = 0
for block_name, block in _h3_repeated_blocks(transformer):
@@ -423,7 +441,19 @@ def install_h3_attention_norm_chunking(
*,
max_tokens: int = H3_MAX_ATTENTION_NORM_TOKENS,
) -> int:
- """Install bounded Q/K head normalization on both H3 repeated stacks."""
+ """Install bounded Q/K head normalization on both H3 repeated stacks.
+
+ Args:
+ transformer: Materialized H3 transformer containing both repeated block stacks.
+ max_tokens: Positive maximum token count normalized by one local operation.
+
+ Returns:
+ Number of Q/K RMSNorm modules configured across both stacks.
+
+ Raises:
+ TypeError: If the materialized upstream attention or RMSNorm structure is unsupported.
+ ValueError: If ``max_tokens`` is invalid or conflicts with an earlier installation.
+ """
max_tokens = _positive_int(max_tokens, "max_tokens")
configured = 0
for block_name, block in _h3_repeated_blocks(transformer):
@@ -478,8 +508,16 @@ def install_h3_lora_projection_chunking(
parameters, children, hooks, and state-dict paths remain owned by that same
module object.
+ Args:
+ transformer: Materialized H3 transformer after PEFT injection.
+ max_tokens: Positive maximum token count projected by one adapted invocation.
+
Returns:
Number of adapted Q/K/V/output projections configured across both stacks.
+
+ Raises:
+ TypeError: If the materialized attention or PEFT projection structure is unsupported.
+ ValueError: If ``max_tokens`` is invalid or conflicts with an earlier installation.
"""
max_tokens = _positive_int(max_tokens, "max_tokens")
configured = 0
@@ -563,7 +601,20 @@ def install_h3_rotary_chunking(
*,
max_tokens: int = H3_MAX_ROTARY_TOKENS,
) -> int:
- """Install the bounded processor on every standard H3 attention instance."""
+ """Install the bounded processor on every standard H3 attention instance.
+
+ Args:
+ transformer: Materialized H3 transformer containing both repeated block stacks.
+ max_tokens: Positive maximum token count rotated by one local operation.
+
+ Returns:
+ Number of H3 attention processors configured across both stacks.
+
+ Raises:
+ ImportError: If the pinned MiniMax H3 attention processor is unavailable.
+ TypeError: If the materialized attention processor structure is unsupported.
+ ValueError: If ``max_tokens`` is invalid or conflicts with an earlier installation.
+ """
max_tokens = _positive_int(max_tokens, "max_tokens")
symbols = require_minimax_h3_support()
processor_class = symbols.MiniMaxH3AttnProcessor
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index db5a6a633..719d1c927 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -425,7 +425,22 @@ def apply_lora(
components: Union[str, List[str]] = "transformer",
overwrite: bool = False,
) -> Any:
- """Apply PEFT and bound Ref2VA projection memory under FSDP2."""
+ """Apply PEFT and bound Ref2VA projection memory under FSDP2.
+
+ Args:
+ target_modules: PEFT target-module patterns forwarded to ``BaseAdapter.apply_lora``.
+ components: Canonical components that receive LoRA.
+ overwrite: Whether to replace an existing default adapter.
+
+ Returns:
+ The PEFT model, per-component PEFT mapping, or empty mapping returned by the base
+ method.
+
+ Raises:
+ TypeError: If Ref2VA FSDP2 projection chunking receives a nonstandard PEFT component,
+ a non-``nn.Module`` base model, or incompatible projection structure.
+ ValueError: If projection chunking conflicts with an earlier installation.
+ """
component_names = (components,) if isinstance(components, str) else tuple(components)
result = super().apply_lora(
target_modules=target_modules,
diff --git a/src/flow_factory/models/minimax_h3/dependency.py b/src/flow_factory/models/minimax_h3/dependency.py
index 936685ee3..f7675f695 100644
--- a/src/flow_factory/models/minimax_h3/dependency.py
+++ b/src/flow_factory/models/minimax_h3/dependency.py
@@ -55,7 +55,7 @@
@dataclass(frozen=True)
class MiniMaxH3Symbols:
- """Hold all upstream classes used by the shared H3 core."""
+ """Hold all upstream symbols used by the shared H3 core."""
ModularPipeline: Type[Any]
MiniMaxH3ModularPipeline: Type[Any]
diff --git a/src/flow_factory/models/minimax_h3/workflow.py b/src/flow_factory/models/minimax_h3/workflow.py
index d0a9b0ea6..b4fc9dd50 100644
--- a/src/flow_factory/models/minimax_h3/workflow.py
+++ b/src/flow_factory/models/minimax_h3/workflow.py
@@ -96,7 +96,19 @@ def load_h3_workflow_pipeline(
def build_h3_component_runtime(adapter: Any) -> ModularPipelineRuntime:
- """Wrap one pruned pipeline and materialize only its training transformer."""
+ """Build the pruned runtime and prepare the H3 transformer for bounded execution.
+
+ Args:
+ adapter: H3 adapter that declares the target transformer and loads the modular pipeline.
+
+ Returns:
+ Runtime with the training transformer materialized and its feed-forward and attention
+ normalization operations configured for bounded token chunks.
+
+ Raises:
+ ValueError: If the adapter targets components outside the H3 training contract.
+ TypeError: If the materialized transformer structure cannot accept the required chunking.
+ """
validate_h3_target_components(adapter)
runtime = ModularPipelineRuntime.from_adapter(adapter, adapter.load_pipeline())
runtime.materialize_components([adapter.transformer_component_name])
diff --git a/src/flow_factory/models/precision.py b/src/flow_factory/models/precision.py
index 4537b7fe8..a6f4fbcd9 100644
--- a/src/flow_factory/models/precision.py
+++ b/src/flow_factory/models/precision.py
@@ -125,7 +125,24 @@ def component_dtype_mapping(
text_encoder_names: Sequence[str],
manifest_declared_names: Sequence[str] | None = None,
) -> dict[str, torch.dtype]:
- """Resolve policies for concrete components, with wider adapter-manifest declarations."""
+ """Resolve user and adapter dtype policies for concrete components.
+
+ Args:
+ user_policy: User-selected dtype policy, which takes precedence.
+ manifest_policy: Adapter-declared fallback dtype policy.
+ component_names: Concrete component names to resolve into the returned mapping.
+ transformer_names: Concrete names matched by the ``transformers`` group selector.
+ text_encoder_names: Concrete names matched by the ``text_encoders`` group selector.
+ manifest_declared_names: Optional superset used only to validate adapter-manifest
+ selectors, including class-declared optional components. Resolution still iterates
+ ``component_names``. Defaults to ``component_names``.
+
+ Returns:
+ Concrete component names mapped to their resolved non-null dtypes.
+
+ Raises:
+ ValueError: If either policy contains a selector outside its declared namespace.
+ """
validate_dtype_policy_selectors(user_policy, declared_names=component_names)
validate_dtype_policy_selectors(
manifest_policy,
@@ -160,7 +177,28 @@ def build_component_load_dtype_kwargs(
requested_names: Sequence[str] | None = None,
preserve_unselected: bool = False,
) -> Dict[str, object]:
- """Build the one native-loader dtype argument for eager or selective loading."""
+ """Build the native-loader dtype argument for eager or selective loading.
+
+ Args:
+ user_policy: User-selected dtype policy, which takes precedence.
+ manifest_policy: Adapter-declared fallback dtype policy.
+ component_names: Concrete component names available to the loader.
+ transformer_names: Concrete names matched by the ``transformers`` group selector.
+ text_encoder_names: Concrete names matched by the ``text_encoders`` group selector.
+ manifest_declared_names: Optional superset used only to validate adapter-manifest
+ selectors, including class-declared optional components. Resolution still iterates
+ ``component_names``. Defaults to ``component_names``.
+ requested_names: Optional subset being loaded by this request.
+ preserve_unselected: Whether the loader mapping should retain an explicit null default so
+ unselected components keep their checkpoint dtype.
+
+ Returns:
+ Empty kwargs when no override applies, or one ``dtype`` keyword accepted by the native
+ component loader.
+
+ Raises:
+ ValueError: If either policy contains a selector outside its declared namespace.
+ """
if isinstance(user_policy, torch.dtype):
return {"dtype": user_policy}
if user_policy is None and isinstance(manifest_policy, torch.dtype):
diff --git a/src/flow_factory/models/runtime/classic.py b/src/flow_factory/models/runtime/classic.py
index 897d35a8c..6b1423e27 100644
--- a/src/flow_factory/models/runtime/classic.py
+++ b/src/flow_factory/models/runtime/classic.py
@@ -53,7 +53,15 @@ def _get_materialized_component(self, name: str) -> Any:
return getattr(self.pipeline, name, None)
def physical_route(self, name: str) -> tuple[str, tuple[str, ...]]:
- """Collapse logical aliases that reference one canonical module object."""
+ """Collapse logical aliases that reference one canonical module object.
+
+ Args:
+ name: Declared logical component name to resolve.
+
+ Returns:
+ Canonical root name and an empty nested path. A declared null component retains its
+ own logical root rather than aliasing through the ``None`` singleton.
+ """
self._validate_declared_names([name])
component = self.declared_components[name]
if component is None:
diff --git a/src/flow_factory/models/wan/_conditioning.py b/src/flow_factory/models/wan/_conditioning.py
index 47503ab55..29e13a7a4 100644
--- a/src/flow_factory/models/wan/_conditioning.py
+++ b/src/flow_factory/models/wan/_conditioning.py
@@ -239,7 +239,27 @@ def prepare_wan_i2v_condition_tensors(
device: torch.device,
last_image: Optional[torch.Tensor] = None,
) -> WanI2VConditionTensors:
- """Encode ordered input frames with posterior mode and build Wan condition channels."""
+ """Encode ordered input frames with posterior mode and build Wan condition channels.
+
+ Args:
+ adapter: Realized Wan I2V adapter owning the pipeline and VAE.
+ image: First-frame pixels shaped ``(B, 3, H, W)``.
+ height: Configured output height.
+ width: Configured output width.
+ num_frames: Configured output frame count.
+ dtype: Floating dtype for returned condition tensors.
+ device: Device used for VAE encoding and returned tensors.
+ last_image: Optional last-frame pixels with the same shape as ``image``.
+
+ Returns:
+ Encoded condition channels and, for expanded-timestep checkpoints, the separate
+ first-frame mask.
+
+ Raises:
+ TypeError: If the requested output dtype or VAE dtype is invalid.
+ ValueError: If input tensors, pixel/latent geometry, endpoint support, or temporal
+ divisibility are incompatible with the realized checkpoint.
+ """
if not isinstance(dtype, torch.dtype) or not dtype.is_floating_point:
raise TypeError(f"Wan I2V condition dtype must be floating, received {dtype!r}")
if not isinstance(image, torch.Tensor) or image.ndim != 4:
@@ -319,6 +339,9 @@ def prepare_wan_i2v_condition_tensors(
).to(device=device, dtype=dtype)
latent_condition = normalize_wan_video_latents(adapter, latent_condition)
+ # The condition VAE time axis belongs to the encoded source. Expanded-timestep checkpoints
+ # encode one frame and broadcast it across the target via first_frame_mask, so it must not be
+ # validated against the rollout's num_latent_frames.
condition_latent_frames = (video_condition.shape[2] - 1) // temporal_scale + 1
expected_latents = (
batch_size,
diff --git a/src/flow_factory/models/wan/wan2_i2v.py b/src/flow_factory/models/wan/wan2_i2v.py
index a08ddf66c..270d30998 100644
--- a/src/flow_factory/models/wan/wan2_i2v.py
+++ b/src/flow_factory/models/wan/wan2_i2v.py
@@ -122,7 +122,10 @@ def load_pipeline(self) -> WanImageToVideoPipeline:
)
def _resolve_pipeline_io_contract(self) -> PipelineIOContract:
- """Resolve checkpoint-specific first/last-frame cardinality."""
+ """Resolve exact-one for ordinary/expanded paths and exact-two for FLF2V weights.
+
+ Wan2.2's VAE-only path retains the class-level one-or-two input superset.
+ """
supports_endpoint_pair = False
if not self.pipeline.config.expand_timesteps:
transformer_configs = tuple(
diff --git a/src/flow_factory/optimizer/loader.py b/src/flow_factory/optimizer/loader.py
index eb3232890..bddc8d366 100644
--- a/src/flow_factory/optimizer/loader.py
+++ b/src/flow_factory/optimizer/loader.py
@@ -97,7 +97,9 @@ def build_optimizer(
One optimizer whose ``param_groups`` carry a ``role_name`` per group.
Raises:
- ValueError: If a configuration has no parameters or an unknown type.
+ ValueError: If a configuration has no parameters, uses an unknown optimizer type,
+ selects Muon without matrix parameters, or the current PyTorch build lacks
+ ``torch.optim.Muon``.
"""
adamw_groups: List[Dict[str, Any]] = []
muon_groups: List[Dict[str, Any]] = []
diff --git a/src/flow_factory/samples/samples.py b/src/flow_factory/samples/samples.py
index 86d2e7dd0..b1a1a1533 100644
--- a/src/flow_factory/samples/samples.py
+++ b/src/flow_factory/samples/samples.py
@@ -97,8 +97,8 @@ class BaseSample:
{"height", "width", "latent_index_map", "log_prob_index_map"}
)
- # Fields that must be transported whenever a concrete sample is reconstructed
- # from a partial cross-rank gather, even when no downstream consumer reads them.
+ # Fields ``gather_samples`` must transport to satisfy concrete-class reconstruction
+ # invariants, even when omitted from the consumer-requested field list.
reconstruction_required_fields: ClassVar[frozenset[str]] = frozenset()
# Denoiseing trajectory
diff --git a/src/flow_factory/trainers/loader.py b/src/flow_factory/trainers/loader.py
index 673e5790e..3b8f16199 100644
--- a/src/flow_factory/trainers/loader.py
+++ b/src/flow_factory/trainers/loader.py
@@ -60,29 +60,20 @@ def _requires_ddp_unused_parameter_detection(
def load_trainer(config: Arguments) -> BaseTrainer:
- """
- Factory function to instantiate trainer based on algorithm type.
-
- Uses registry pattern for automatic trainer discovery and loading.
- Supports both built-in trainers and custom algorithms via python paths.
+ """Instantiate the configured trainer after validating its execution plan.
Args:
- config: Configuration containing trainer_type and all hyperparameters
+ config: Parsed configuration containing the trainer, model, optimizer, and backend policy.
Returns:
- An instance of a BaseTrainer subclass
+ The initialized trainer selected by ``config.training_args.trainer_type``.
Raises:
- ImportError: If the trainer is not registered or cannot be imported
-
- Examples:
- # Using built-in trainer
- config.training_args.trainer_type = "grpo"
- trainer = load_trainer(config)
-
- # Using custom trainer
- config.training_args.trainer_type = "my_package.trainers.PPOTrainer"
- trainer = load_trainer(config)
+ ImportError: If the requested trainer cannot be resolved or imported.
+ TypeError: If the registry entry does not resolve to a trainer class.
+ ValueError: If the trainer/adapter contract or distributed optimizer/checkpoint plan is
+ unsupported.
+ RuntimeError: If an FSDP2 checkpoint plan lacks the required plugin state.
"""
# Resolve and validate algorithm semantics before constructing an Accelerator or
# loading model weights. A stale trainer/argument registry pairing must fail with
diff --git a/src/flow_factory/trainers/multirole/backend.py b/src/flow_factory/trainers/multirole/backend.py
index e877d9956..860d082db 100644
--- a/src/flow_factory/trainers/multirole/backend.py
+++ b/src/flow_factory/trainers/multirole/backend.py
@@ -73,17 +73,19 @@ def configure_checkpointing_backend_plan(
accelerator: Accelerator,
training_args: TrainingArguments,
) -> bool:
- """Select a checkpoint owner before model loading and distributed preparation.
+ """Select one checkpointing owner for the active distributed plan.
Args:
accelerator: Runtime backend whose checkpointing policy is being configured.
training_args: Parsed algorithm arguments containing the model checkpoint policy.
Returns:
- Whether a previously realized adapter must disable model checkpointing.
+ True if model-level checkpointing was disabled and a caller holding an already-realized
+ adapter must remove its checkpoint wrappers; otherwise False.
Raises:
ValueError: If FSDP2 is paired with a selective model checkpoint policy.
+ RuntimeError: If FSDP2 backend checkpointing is selected without an FSDP plugin.
"""
if accelerator.distributed_type != DistributedType.FSDP:
return False
diff --git a/src/flow_factory/utils/dist.py b/src/flow_factory/utils/dist.py
index 33af7e60d..51415a076 100644
--- a/src/flow_factory/utils/dist.py
+++ b/src/flow_factory/utils/dist.py
@@ -502,10 +502,10 @@ def gather_samples(
Args:
accelerator: Accelerator instance.
- samples: Local samples on this rank.
- field_names: Fields to gather. When ``'extra_kwargs'`` is included,
- each key inside the dict is gathered independently and reassembled.
- Concrete sample reconstruction fields are added automatically.
+ samples: Local samples on this rank. All entries must share one concrete sample class.
+ field_names: Consumer-requested fields to gather. When ``extra_kwargs`` is included, each
+ key is gathered independently. Fields declared by the concrete class in
+ ``reconstruction_required_fields`` are always added before reconstruction.
device: Target device for tensor fields in the returned samples.
Returns:
From 13dd5d2c3d88f60c54618488ca5f645ad9774b5a Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 07:39:14 +0800
Subject: [PATCH 67/76] [docs] update final GPU validation status
---
README.md | 28 +++++++++++++++-------------
examples/README.md | 15 ++++++++-------
guidance/datasets.md | 21 +++++++++++++--------
guidance/gpu_validation.md | 26 ++++++++++++++++++++++----
tests/docs/test_minimax_h3_docs.py | 21 ++++++++++++---------
5 files changed, 70 insertions(+), 41 deletions(-)
diff --git a/README.md b/README.md
index c62bb489f..4c49a7424 100644
--- a/README.md
+++ b/README.md
@@ -86,9 +86,9 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
| First/Last-Frame-to-Video | Wan2.1-FLF2V-14B-720P | 14B | wan2_i2v |
| Text-to-Audio-Video | LTX-2 | 19B | ltx2_t2av |
- | LTX-2.3 | 22B | ltx2_t2av |
+ | LTX-2.3 (Diffusers) | 22B | ltx2_t2av |
| Image-to-Audio-Video | LTX-2 | 19B | ltx2_i2av |
- | LTX-2.3 | 22B | ltx2_i2av |
+ | LTX-2.3 (Diffusers) | 22B | ltx2_i2av |
| Text-to-Audio-Video | MiniMax H3 T2VA | 33B | minimax-h3-t2va |
| First/Last-Frame-to-Audio-Video | MiniMax H3 FL2VA | 33B | minimax-h3-fl2va |
| Ordered-Reference-to-Audio-Video | MiniMax H3 Ref2VA | 33B | minimax-h3-ref2va |
@@ -104,11 +104,12 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
> offline DPO shares that exact realization across chosen and rejected arms. See the
> [offline model matrix](guidance/datasets.md#offline-model-support).
-> **MiniMax H3 status:** the T2VA debug and
-> [native-quality FSDP2](examples/grpo/lora/minimax_h3_t2va/quality_720p_fsdp2.yaml)
-> paths are real-weight
-> validated; a completed long-run reward trend is not claimed. FL2VA and Ref2VA remain
-> schema/API and local offline-path validated, pending the documented real-weight GPU matrix.
+> **MiniMax H3 status:** T2VA, FL2VA, and Ref2VA completed all 36 real-weight smoke cells in
+> the documented matrix: three workflows x DDP/DeepSpeed ZeRO-2/FSDP2 x
+> GRPO/SFT/offline DPO/TDM. The FL2VA first-plus-last SFT/offline-DPO gate also passed.
+> The T2VA [native-quality FSDP2](examples/grpo/lora/minimax_h3_t2va/quality_720p_fsdp2.yaml)
+> path has separate initialization, checkpoint, decode, and evaluation coverage. These results
+> do not claim a completed long-run reward trend, convergence, or numerical parity.
> H3 requires B=1, has no CFG, uses neutral guidance `1.0`, and
> keeps separate video/audio trajectories.
> Video uses shift 12, audio uses shift 3, and the model predicts data-ward velocity.
@@ -135,10 +136,10 @@ This experimental feature leverages `diffusers`'s `transformer.set_attention_bac
See [`Algorithm Guidance`](guidance/algorithms.md) for more information.
-> Models and algorithms are decoupled at the framework interface. Validation status varies by example.
-> Training-verified examples carry hardware and reward-trend evidence.
-> MiniMax H3 T2VA has real-weight LoRA validation; FL2VA, Ref2VA, and unlisted
-> combinations require separate training evidence.
+> Models and algorithms are decoupled at the framework interface. The documented ten-mode
+> real-weight smoke matrix completed all 120 model/backend/algorithm cells. Combinations outside
+> that matrix still require separate execution evidence, and smoke completion is not a claim of
+> reward improvement.
# 💾 Hardware Requirements
@@ -147,7 +148,7 @@ See [`Algorithm Guidance`](guidance/algorithms.md) for more information.
## Installation
```bash
-git clone https://github.com/Jayce-Ping/Flow-Factory.git
+git clone https://github.com/X-GenGroup/Flow-Factory.git
cd Flow-Factory
pip install -e .
```
@@ -244,7 +245,8 @@ Prompt and input-condition encodings are cached. Target, chosen, and rejected me
encoded on the fly; their VAE latents are never stored in the preprocessing cache. One offline
epoch is one complete dataloader traversal sharded by PyTorch's official `DistributedSampler`. See the
[dataset guide](guidance/datasets.md#offline-v2-records) for the full schema and cadence rules, and
-the [GPU validation plan](guidance/gpu_validation.md) for the 120-job model/backend/algorithm matrix.
+the [completed GPU validation matrix](guidance/gpu_validation.md) for the 120 main jobs and 24
+additional dynamic gates.
The [offline smoke builder](dataset/offline_smoke/README.md) reconstructs independent SFT and
offline-DPO mini datasets for every currently implemented image, video, and audio-video profile.
diff --git a/examples/README.md b/examples/README.md
index 2765c7800..7d93dbd34 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -88,13 +88,14 @@ The T2VA `debug.yaml` recipe is real-weight validated with the 61 GB checkpoint
(61.74 GiB transformer):
1 GPU and 16 GPUs across two nodes completed CPS rollout, video/audio decode,
CLAP reward, GRPO replay/backward/optimizer step, and LoRA checkpoint save/resume.
-Its 64x96 canvas is intentionally a correctness geometry. The quality-oriented T2VA
-default is now the shared-`vid_prompt`, LoRA-rank-64 baseline aligned with the LTX2
-T2AV recipe and uses both CLAP and ImageBind rewards. It is configuration/API
-validated; no completed long-run reward trend is claimed. FL2VA and Ref2VA are also
-schema/API and local offline-path validated, rather than claims of real-weight training stability
-or reward improvement. The complete follow-up campaign is defined in the
-[GPU validation plan](../guidance/gpu_validation.md).
+Its 64x96 canvas is intentionally a correctness geometry. The PR #220 real-weight smoke campaign
+subsequently completed all 36 H3 main cells: T2VA, FL2VA, and Ref2VA across
+DDP/DeepSpeed ZeRO-2/FSDP2 and GRPO/SFT/offline DPO/TDM. The FL2VA first-plus-last
+SFT/offline-DPO variant gate also passed. The quality-oriented T2VA default remains the
+shared-`vid_prompt`, LoRA-rank-64 baseline aligned with the LTX2 T2AV recipe and uses both CLAP
+and ImageBind rewards. These smoke results establish execution coverage, not a completed
+long-run reward trend, convergence, or numerical parity. See the
+[GPU validation matrix](../guidance/gpu_validation.md).
The T2VA `quality_720p_fsdp2.yaml` recipe is the active native-quality path:
768x1344, 124 frames, 24 denoising steps, LoRA rank 64 / alpha 128, and two
diff --git a/guidance/datasets.md b/guidance/datasets.md
index 81da9aaac..c4ebe0eea 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -256,14 +256,17 @@ output semantics.
|---|---|---|
| Supported | `sd3-5`, `flux1`, `flux1-kontext`, `flux2`, `flux2-klein`, `qwen-image`, `qwen-image-edit-plus`, `z-image`, `bagel`, `sensenova` | Image-output codecs with adapter-specific geometry and packing. SenseNova uses the existing grouped `images` input with within-type order, not heterogeneous references. |
| Supported | `wan2_t2v` | Video targets require `fps`; the codec resamples to configured frames/rate and samples the Wan VAE posterior on the fly. |
-| Supported | `wan2_i2v` | Input media binds a required `first_frame` image and an optional `last_frame` image. Condition pixels are cached at configured geometry, then encoded with VAE posterior mode once per batch. Expanded-timestep TI2V checkpoints accept the first frame only because official Diffusers ignores a last image in that mode. Video targets require `fps`. Offline execution is B=1. |
+| Supported | `wan2_i2v` | Input media uses a checkpoint-specific contract. Expanded-timestep TI2V and standard Wan2.1 I2V accept exactly `first_frame`; dedicated Wan2.1 FLF2V requires both `first_frame` and `last_frame`; Wan2.2 I2V-A14B accepts `first_frame` plus an optional VAE-only `last_frame`. Condition pixels are cached at configured geometry and encoded with VAE posterior mode once per batch. Video targets require `fps`; offline execution is B=1. |
| Supported | `ltx2_t2av`, `ltx2_i2av` | Every candidate is an exact ordered `(video, audio)` pair with required `fps` and `sample_rate`. Both streams are aligned to the official LTX2 clock and encoded/packed on the fly. I2AV requires the `first_frame` image slot, substitutes its posterior-mode first latent into each target, and excludes the pinned tokens with an active mask. |
| Supported | `minimax-h3-t2va`, `minimax-h3-fl2va`, `minimax-h3-ref2va` | Every candidate is an exact ordered `(video, audio)` pair. FL2VA accepts `first_frame`, `last_frame`, or both slots; Ref2VA accepts 1-12 globally ordered references and requires at least one image or video. Conditioned workflows realize one official prefix per batch, shared by both offline-DPO candidates and policy/reference forwards. H3 remains B=1. |
-Wan first/last semantics use generic semantic slots, not model-specific schema keys. The first
-frame is required; the last frame is optional. Unslotted input remains a positional convenience,
-but an explicit slot is recommended for sparse or generated manifests. The target is the complete
-generated video: its first frame, and its final frame when provided, correspond to the conditions.
+Wan endpoint semantics use generic semantic slots, not model-specific schema keys. The realized
+slot cardinality is checkpoint-specific: expanded-timestep TI2V and standard Wan2.1 I2V accept
+exactly `first_frame`; dedicated Wan2.1 FLF2V requires both endpoints; Wan2.2 I2V-A14B accepts
+`first_frame` plus an optional VAE-only `last_frame`. Unslotted input remains a positional
+convenience, but explicit slots are recommended for sparse or generated manifests. The target is
+the complete generated video, whose conditioned endpoint or endpoints must correspond to the
+supplied images.
```jsonl
{"schema_version":2,"input":{"prompt":"A paper boat crosses the pond.","media":[{"type":"image","path":"conditions/first.png","slot":"first_frame"}]},"supervision":{"type":"demonstration","target":{"media":[{"type":"video","path":"targets/first-only.mp4","fps":24.0}]}},"metadata":{}}
@@ -407,9 +410,11 @@ checkpoint save/resume. Its 64x96 canvas validates
correctness and memory fit, not visual quality or reward improvement.
`quality_720p_fsdp2.yaml` has real-weight initialization, checkpoint, native-resolution
decode, and evaluation coverage; no long-run reward trend is claimed. The aligned default uses
-the shared `dataset/vid_prompt` source, LoRA rank 64, and CLAP plus ImageBind rewards; it remains a
-configuration/API-validated baseline without a published long-run trend. FL2VA and Ref2VA remain
-schema/API-validated starting points.
+the shared `dataset/vid_prompt` source, LoRA rank 64, and CLAP plus ImageBind rewards. The PR #220
+smoke campaign completed all 36 H3 main cells: T2VA, FL2VA, and Ref2VA across
+DDP/DeepSpeed ZeRO-2/FSDP2 and GRPO/SFT/offline DPO/TDM. The FL2VA first-plus-last
+SFT/offline-DPO variant gate also passed. This is execution coverage, not a published long-run
+reward trend, convergence, or numerical-parity claim.
### T2VA: `minimax-h3-t2va`
diff --git a/guidance/gpu_validation.md b/guidance/gpu_validation.md
index 23b8a6d37..8c6c6a3dd 100644
--- a/guidance/gpu_validation.md
+++ b/guidance/gpu_validation.md
@@ -1,8 +1,26 @@
-# GPU Validation Plan
+# GPU Validation Matrix
-This document is the handoff contract for real-weight GPU validation. It does
-not claim that an unexecuted combination is supported. A combination becomes
-validated only after its artifacts satisfy the acceptance criteria below.
+This document defines the real-weight GPU validation contract and records the PR #220 result.
+A combination is validated only after its execution evidence satisfies the acceptance criteria
+below.
+
+## PR #220 result
+
+The complete dynamic smoke scope passed **144/144** unique jobs:
+
+- **120/120 main jobs**: 10 semantic modes x DDP/DeepSpeed ZeRO-2/FSDP2 x
+ GRPO/SFT/offline DPO/TDM.
+- **22/22 checkpoint-variant jobs**: Wan 2.1/2.2 T2V and I2V variants, all six strict Wan A14B
+ dual-transformer routing jobs, LTX 2.3 T2AV/I2AV, and H3 FL2VA first-plus-last coverage.
+- **2/2 supplemental jobs**: Flux1-Kontext image-to-image SFT and offline DPO.
+
+All **132/132** checkpoint-variant backend/algorithm cells also passed static configuration and
+contract validation. Four positive Muon jobs passed for DDP/FSDP2 with SFT and mixed-role TDM;
+the DeepSpeed ZeRO-2 negative gate rejected Muon before model loading as intended.
+
+These are reduced-geometry, finite-length execution smokes. They establish model loading,
+distributed routing, forward/backward, optimizer, and finite-data behavior; they do not claim
+convergence, long-run reward improvement, quality parity, or numerical parity.
## Environment gate
diff --git a/tests/docs/test_minimax_h3_docs.py b/tests/docs/test_minimax_h3_docs.py
index cb4f5e0f2..edbe2af12 100644
--- a/tests/docs/test_minimax_h3_docs.py
+++ b/tests/docs/test_minimax_h3_docs.py
@@ -32,10 +32,10 @@ def test_readme_documents_h3_links_dependency_and_limits() -> None:
"The configurations under `examples/` have been verified to yield measurable "
"performance gains."
) not in text
- assert "Validation status varies by example" in text
- assert "hardware and reward-trend evidence" in text
- assert "MiniMax H3 T2VA has real-weight LoRA validation" in text
- assert "FL2VA and Ref2VA remain" in text
+ assert "all 120 model/backend/algorithm cells" in text
+ assert "smoke completion is not a claim of" in text
+ assert "all 36 real-weight smoke cells" in text
+ assert "FL2VA first-plus-last" in text
assert "T2VA is real-weight validated on 1 and 16 GPUs" not in text
assert text.count("30B | ") == 3
@@ -59,7 +59,7 @@ def test_readme_documents_h3_links_dependency_and_limits() -> None:
"N transitions",
"N + 1 states",
"30B",
- "completed long-run reward trend is not claimed",
+ "do not claim a completed long-run reward trend",
"[Datasets](guidance/datasets.md)",
):
assert required in text
@@ -71,9 +71,9 @@ def test_examples_readme_links_h3_and_separates_validation_levels() -> None:
relative_link = f"../{root_link}"
assert relative_link in text
assert (ROOT / root_link).is_file()
- assert "schema/API and local offline-path validated" in text
- assert "GPU validation plan" in text
- assert "hardware" in text
+ assert "all 36 H3 main cells" in text
+ assert "GPU validation matrix" in text
+ assert "execution coverage" in text
assert "reward" in text
assert "61 GB" in text
assert "ImageBind" in text
@@ -81,10 +81,13 @@ def test_examples_readme_links_h3_and_separates_validation_levels() -> None:
assert "NonCommercial" in text
-def test_gpu_validation_plan_declares_the_complete_smoke_matrix() -> None:
+def test_gpu_validation_matrix_declares_scope_and_completed_result() -> None:
text = _text("guidance/gpu_validation.md")
assert "10 x 3 x 4 = 120 jobs" in text
+ for result in ("144/144", "120/120", "22/22", "2/2", "132/132"):
+ assert result in text
+ assert "Four positive Muon jobs passed" in text
for mode in (
"sd35-t2i",
"bagel-mri2i",
From 3a6d414785b1683c594a27fc2c364d1a90e95284 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 07:49:19 +0800
Subject: [PATCH 68/76] [docs] clarify GPU evidence boundaries
---
guidance/gpu_validation.md | 24 +++++++++++++++---------
1 file changed, 15 insertions(+), 9 deletions(-)
diff --git a/guidance/gpu_validation.md b/guidance/gpu_validation.md
index 8c6c6a3dd..abe09dcd4 100644
--- a/guidance/gpu_validation.md
+++ b/guidance/gpu_validation.md
@@ -1,26 +1,32 @@
# GPU Validation Matrix
-This document defines the real-weight GPU validation contract and records the PR #220 result.
-A combination is validated only after its execution evidence satisfies the acceptance criteria
+This document defines the real-weight GPU validation contract and records the PR #220 execution
+result. The historical direct runs establish successful launch and training completion; a future
+formal campaign should additionally capture every artifact listed in the acceptance criteria
below.
## PR #220 result
-The complete dynamic smoke scope passed **144/144** unique jobs:
+The complete dynamic smoke scope reached its successful terminal marker for **144/144** unique
+jobs:
- **120/120 main jobs**: 10 semantic modes x DDP/DeepSpeed ZeRO-2/FSDP2 x
GRPO/SFT/offline DPO/TDM.
-- **22/22 checkpoint-variant jobs**: Wan 2.1/2.2 T2V and I2V variants, all six strict Wan A14B
- dual-transformer routing jobs, LTX 2.3 T2AV/I2AV, and H3 FL2VA first-plus-last coverage.
+- **22/22 formal variant-gate jobs**: Wan 2.1/2.2 T2V and I2V routing variants (including
+ corrected redundant FLF2V coverage), all six strict Wan A14B dual-transformer routing jobs,
+ LTX 2.3 T2AV/I2AV, and H3 FL2VA first-plus-last coverage.
- **2/2 supplemental jobs**: Flux1-Kontext image-to-image SFT and offline DPO.
All **132/132** checkpoint-variant backend/algorithm cells also passed static configuration and
contract validation. Four positive Muon jobs passed for DDP/FSDP2 with SFT and mixed-role TDM;
the DeepSpeed ZeRO-2 negative gate rejected Muon before model loading as intended.
-These are reduced-geometry, finite-length execution smokes. They establish model loading,
-distributed routing, forward/backward, optimizer, and finite-data behavior; they do not claim
-convergence, long-run reward improvement, quality parity, or numerical parity.
+These are reduced-geometry, finite-length execution smokes. They establish archived real-weight
+execution coverage for model loading, distributed backends, optimizer paths, and finite-data
+termination. Capability-sensitive instrumented gates additionally checked routing and updates,
+but earlier direct runs did not uniformly capture the complete command, environment, and metrics
+artifact set required below. These results do not claim convergence, long-run reward improvement,
+quality parity, or numerical parity.
## Environment gate
@@ -180,7 +186,7 @@ backends and all four algorithms.
| Family mode | Additional checkpoint variants |
|---|---|
-| Wan T2V | `Wan2.1-T2V-14B-Diffusers`, `Wan2.2-TI2V-5B-Diffusers`, `Wan2.2-T2V-A14B-Diffusers` |
+| Wan T2V/TI2V family | `Wan2.1-T2V-14B-Diffusers`, `Wan2.2-TI2V-5B-Diffusers`, `Wan2.2-T2V-A14B-Diffusers` |
| Wan I2V first-only | `Wan2.1-I2V-14B-480P-Diffusers`, `Wan2.1-I2V-14B-720P-Diffusers`, `Wan2.2-I2V-A14B-Diffusers` |
| Wan first/last | `Wan2.2-I2V-A14B-Diffusers` |
| LTX2 T2AV and I2AV | `dg845/LTX-2.3-Diffusers` |
From 8d69cb539472dc3523fa20494fc3a26a8092a59c Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 07:51:21 +0800
Subject: [PATCH 69/76] [optimizer] docs: correct Muon availability guidance
---
.agents/knowledge/dependencies.md | 13 ++++++++++---
.agents/knowledge/topics/component_variants.md | 5 +++--
guidance/workflow.md | 3 ++-
src/flow_factory/optimizer/loader.py | 2 +-
4 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/.agents/knowledge/dependencies.md b/.agents/knowledge/dependencies.md
index d6d52ef18..79198cd3e 100644
--- a/.agents/knowledge/dependencies.md
+++ b/.agents/knowledge/dependencies.md
@@ -38,7 +38,7 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
| Package | Min Version | Purpose |
|---------|-------------|---------|
-| `torch` | >= 2.6.0 | PyTorch core |
+| `torch` | >= 2.6.0 | PyTorch core and AdamW baseline; Muon needs the optional API noted below |
| `torchvision` | >= 0.19.0 | Vision utilities |
| `torchaudio` | >= 2.4.0 | Audio I/O (audio / audio-video models, CLAP) |
| `transformers` | >= 4.57.1 | Text encoders, tokenizers |
@@ -56,6 +56,13 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
- Only **ZeRO-1** and **ZeRO-2** are supported. ZeRO-3 is broken for reward model sharding (constraint #10).
- DeepSpeed is optional — Accelerate alone handles most distributed scenarios.
+### Muon
+- The core `torch>=2.6.0` floor does not guarantee `torch.optim.Muon`. Selecting
+ `optimizer: muon` requires a build that exposes that API (included in standard releases from
+ PyTorch 2.9); runtime capability detection remains authoritative.
+- Muon is supported with DDP and FSDP2. The pre-load optimizer/backend validator rejects
+ DeepSpeed and FSDP1 before pretrained weights are loaded.
+
### diffusers
- Use the released `diffusers>=0.40.0` package as the authoritative API. The repository submodule
may be used for upstream development, but must not silently override the declared runtime dependency.
@@ -101,5 +108,5 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
## Cross-refs
-- `constraints.md` #10 (DeepSpeed ZeRO-3 unsupported)
-- `architecture.md` "Configuration Hierarchy" (hparams structure)
+- UP: [`constraints.md` #10](constraints.md#10-deepspeed-zero-3-is-unsupported), [Architecture Configuration Hierarchy](architecture.md#configuration-hierarchy)
+- PEER: [Component Variants](topics/component_variants.md)
diff --git a/.agents/knowledge/topics/component_variants.md b/.agents/knowledge/topics/component_variants.md
index a6c1d5735..e3793f6e0 100644
--- a/.agents/knowledge/topics/component_variants.md
+++ b/.agents/knowledge/topics/component_variants.md
@@ -154,7 +154,8 @@ its children's groups as one list. An all-AdamW run still gets a plain
Muon therefore gives one variant **two** parameter groups, which is why
`OptimizationRole.optimizer_group_ids` is a tuple. It requires a PyTorch build that
-exposes `torch.optim.Muon`. DeepSpeed is rejected as unverified because it rebuilds
+exposes `torch.optim.Muon` (included in standard releases from PyTorch 2.9); the runtime feature
+check, rather than the version string alone, is authoritative. DeepSpeed is rejected as unverified because it rebuilds
its own optimizer wrapper, while FSDP1 is rejected because its flat parameters erase
the required matrix rank; the supported distributed plans are DDP and FSDP2.
@@ -192,5 +193,5 @@ family describes it.
## Cross-refs
-- UP: [`constraints.md` #10](../constraints.md), [`architecture.md` Component Management](../architecture.md#component-management)
+- UP: [`constraints.md` #10](../constraints.md#10-deepspeed-zero-3-is-unsupported), [`architecture.md` Component Management](../architecture.md#component-management)
- PEER: [Component Runtime](component_runtime.md), [Structured Trajectory](structured_trajectory.md), [Autocast and Parameter Swaps](autocast_param_swap.md)
diff --git a/guidance/workflow.md b/guidance/workflow.md
index a52e24c3d..3ad9f6f82 100644
--- a/guidance/workflow.md
+++ b/guidance/workflow.md
@@ -669,7 +669,8 @@ AdamW for its biases, normalization scales and embeddings, which the `fallback_`
fields configure. `optimizer/loader.py` wraps that pair in a `CompositeOptimizer` so
the framework still prepares exactly one root. An all-AdamW run gets a plain
`torch.optim.AdamW`, unchanged. Muon requires a PyTorch build that exposes
-`torch.optim.Muon` (2.10 or newer in supported environments). Muon combined with
+`torch.optim.Muon` (included in standard releases from PyTorch 2.9; runtime capability detection
+is authoritative). Muon combined with
DeepSpeed is refused at startup as unverified, and FSDP1 flattens matrices into
incompatible parameters; use DDP or FSDP2.
diff --git a/src/flow_factory/optimizer/loader.py b/src/flow_factory/optimizer/loader.py
index bddc8d366..71b78a153 100644
--- a/src/flow_factory/optimizer/loader.py
+++ b/src/flow_factory/optimizer/loader.py
@@ -39,7 +39,7 @@ def validate_muon_available() -> None:
if not hasattr(torch.optim, "Muon"):
raise ValueError(
f"torch.optim.Muon is unavailable in PyTorch {torch.__version__}. Install a "
- "PyTorch build that provides Muon (2.10 or newer in supported environments), "
+ "PyTorch build that provides Muon (standard releases include it from 2.9), "
"or select the adamw optimizer."
)
From 60201685fc89f75fff474a89bb4bbcaaecda4e89 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 07:53:01 +0800
Subject: [PATCH 70/76] [agents] docs: align knowledge with current contracts
---
.agents/knowledge/README.md | 5 +++
.agents/knowledge/architecture.md | 16 +++++-----
.agents/knowledge/constraints.md | 31 ++++++++++++++-----
.agents/knowledge/docs_maintenance.md | 12 +++++--
.../knowledge/topics/adapter_conventions.md | 5 +--
.../knowledge/topics/autocast_param_swap.md | 6 ++--
.agents/knowledge/topics/component_runtime.md | 22 ++++++++++++-
.agents/knowledge/topics/dtype_precision.md | 7 ++---
.agents/knowledge/topics/fix_patterns.md | 5 ++-
.agents/knowledge/topics/minimax_h3.md | 17 +++++-----
.agents/knowledge/topics/parity_testing.md | 4 +--
.agents/knowledge/topics/sample_lifecycle.md | 22 +++++++++----
.agents/knowledge/topics/samplers.md | 4 +--
.agents/knowledge/topics/timestep_sigma.md | 5 ++-
.../topics/train_inference_consistency.md | 5 ++-
.cursor/rules/agents-docs-maintenance.mdc | 2 +-
AGENTS.md | 6 ++--
17 files changed, 115 insertions(+), 59 deletions(-)
diff --git a/.agents/knowledge/README.md b/.agents/knowledge/README.md
index ba09d48d4..60f7eaa77 100644
--- a/.agents/knowledge/README.md
+++ b/.agents/knowledge/README.md
@@ -7,13 +7,18 @@
| Touching dtype/precision, mixed precision config, debugging NaN/overflow | `topics/dtype_precision.md` |
| Editing a trainer `optimize()` loop / autocast scope, ref/EMA/named param swaps | `topics/autocast_param_swap.md` |
| Adding or modifying a model adapter | `topics/adapter_conventions.md` |
+| Changing offline `PipelineIOContract`, condition-state preparation, output codecs, or output geometry | `topics/adapter_conventions.md`, `topics/component_runtime.md` |
| Changing component discovery, loading, lifecycle, or distributed preparation | `topics/component_runtime.md` |
+| Changing FSDP loading or activation-checkpoint ownership | `topics/component_runtime.md` |
| Touching rollout collection, replay bridges, index maps, or multi-component order | `topics/structured_trajectory.md` |
| Adding an algorithm that trains several model copies at once, or changing per-variant LoRA/full storage or variant checkpointing | `topics/component_variants.md` |
+| Changing optimizer roles, Muon, `CompositeOptimizer`, or optimizer/backend compatibility | `topics/component_variants.md`, `dependencies.md` |
| Adding adapter, upgrading diffusers, debugging output quality | `topics/parity_testing.md` |
+| Touching MiniMax H3 workflows, dependency pins, ordered references, or H3 memory policies | `topics/minimax_h3.md` |
| Touching `TimeSampler`, `adapter.forward(t=...)`, `timestep_range`, `flow_match_sigma` | `topics/timestep_sigma.md` |
| Editing `data_utils/sampler*`, hparams sampler/batch fields | `topics/samplers.md` |
| Touching `sample()`/`optimize()` data flow, debugging `sample()`/`optimize()` OOM, adding high-resolution / video example configs | `topics/sample_lifecycle.md` |
+| Changing `BaseSample`, partial sample gathering, or concrete-sample reconstruction | `topics/sample_lifecycle.md` |
| After completing a bug fix | `topics/fix_patterns.md` |
| Changing `pyproject.toml`, deps, install commands | `dependencies.md` |
| Adding or editing `.agents/` documentation | `docs_maintenance.md` |
diff --git a/.agents/knowledge/architecture.md b/.agents/knowledge/architecture.md
index 84baee949..226bae25a 100644
--- a/.agents/knowledge/architecture.md
+++ b/.agents/knowledge/architecture.md
@@ -89,12 +89,14 @@ All four registries map string keys → lazy import paths. Resolution: registry
| `nft` | `DiffusionNFTTrainer` | Decoupled | `BaseTrainer` |
| `awm` | `AWMTrainer` | Decoupled | `BaseTrainer` |
| `crd` | `CRDTrainer` | Decoupled | `BaseTrainer` |
-| `diffusion-opd` | `DiffusionOPDTrainer` | Distillation (on-policy) | `BaseTrainer` |
-| `dmd2` | `DMD2Trainer` | Distillation | `BaseTrainer` |
-| `tdm` | `TDMTrainer` | Distillation | `BaseTrainer` |
-| `tdm-r1` | `TDMR1Trainer` | Distillation + reward | `BaseTrainer` |
+| `diffusion-opd` | `DiffusionOPDTrainer` | Distillation, generation + no feedback | `BaseTrainer` |
+| `dmd2` | `DMD2Trainer` | Distillation, generation + no feedback, ODE | `BaseTrainer` |
+| `tdm` | `TDMTrainer` | Distillation, generation + no feedback, ODE | `BaseTrainer` |
+| `tdm-r1` | `TDMR1Trainer` | Decoupled, generation + runtime reward, ODE | `TDMTrainer` |
-**Flat hierarchy**: New trainers inherit from `BaseTrainer` directly. The sanctioned exceptions are `GRPOGuardTrainer → GRPOTrainer` and `DPPOTrainer → GRPOTrainer` (strict GRPO loss variants; see constraint #11).
+**Flat hierarchy**: New trainers inherit from `BaseTrainer` directly. The sanctioned existing
+extensions are `GRPOGuardTrainer → GRPOTrainer`, `DPPOTrainer → GRPOTrainer`, and
+`TDMR1Trainer → TDMTrainer`; see constraint #11.
**Model Adapters** (`models/registry.py`):
| Key | Class | Task |
@@ -288,13 +290,13 @@ Details: `topics/component_variants.md`.
### Reward Processing
`RewardProcessor` dispatches by model type:
-- **Pointwise**: batch by `batch_size`
+- **Pointwise**: applicable sub-batches of at most `batch_size`
- **Groupwise**: group by `unique_id` (local or distributed path)
- **Multi-reward**: weighted aggregation
- **Async**: optional non-blocking computation
### Advantage Computation
-`AdvantageProcessor` (`advantage/advantage_processor.py`): communication-aware, auto-selects gather vs local path. Strategies: `"sum"` (GRPO) and `"gdpo"`. All reward-based trainers delegate to `self.advantage_processor.compute_advantages()`; the distillation trainer `diffusion-opd` is the exception (its `prepare_feedback()` is a no-op — no reward/advantage stage).
+`AdvantageProcessor` (`advantage/advantage_processor.py`): communication-aware, auto-selects gather vs local path. Strategies: `"sum"` (GRPO) and `"gdpo"`. Runtime-reward trainers delegate to `self.advantage_processor.compute_advantages()`. Feedback-`none` trainers (`diffusion-opd`, DMD2, and TDM) bypass reward and advantage stages structurally.
### Configuration Hierarchy
```
diff --git a/.agents/knowledge/constraints.md b/.agents/knowledge/constraints.md
index 0e506e9f0..77fcc746b 100644
--- a/.agents/knowledge/constraints.md
+++ b/.agents/knowledge/constraints.md
@@ -12,7 +12,11 @@ These constraints MUST NOT be violated. Consult this file before making any code
The four registries (`_TRAINER_REGISTRY`, `_MODEL_ADAPTER_REGISTRY`, `_REWARD_MODEL_REGISTRY`, `_ACCELERATOR_REGISTRY`) map string identifiers to **fully qualified Python class paths** for lazy import. If you move, rename, or restructure a class, the corresponding registry entry MUST be updated, or `ImportError` will occur at runtime.
### 2. Registry Identifier Convention
-Registry keys are **case-insensitive** (lowered at lookup). Model adapter keys use lowercase with hyphens (e.g., `flux1-kontext`). Trainer keys use lowercase (e.g., `grpo-guard`). Reward keys use lowercase (e.g., `pickscore`). New entries must follow the same convention.
+Registry keys are **case-insensitive** (lowered at lookup). Preserve the canonical registered
+spelling: most model adapter keys use lowercase with hyphens (for example `flux1-kontext`), while
+the public Wan/LTX2 keys retain underscores (for example `wan2_t2v` and `ltx2_t2av`). Trainer and
+reward keys use lowercase (for example `grpo-guard` and `pickscore`). New entries must choose one
+canonical lowercase spelling and use it consistently across registries, arguments, and examples.
### 3. Dynamic Import Fallback
All four registries support a **direct Python path** fallback (e.g., `my_package.models.CustomAdapter`). If an identifier is not found in the registry, it is treated as a fully qualified import path. Do not break this two-mode resolution logic.
@@ -47,8 +51,8 @@ either axis from batch fields or make it user-configurable independently of `tra
### 7. Coupled vs Decoupled Paradigm
- **Coupled** (GRPO, GRPO-Guard, DPPO): Training timesteps are coupled with SDE-based sampling. Requires log-probability computation. Must use SDE dynamics (`Flow-SDE`, `Dance-SDE`, `CPS`).
-- **Decoupled** (SFT, offline DPO, online DPO, NFT, AWM, DGPO, CRD): Training timesteps are decoupled from sampling. Can use any dynamics including `ODE`.
-- **Distillation** (`diffusion-opd`): On-policy multi-teacher distillation; dynamics-agnostic (ODE or SDE) and has no reward/advantage stage.
+- **Decoupled** (SFT, offline DPO, online DPO, NFT, AWM, DGPO, CRD, TDM-R1): Training timesteps are decoupled from sampling. They may use ODE subject to algorithm-specific rules; TDM-R1 requires ODE.
+- **Distillation** (`diffusion-opd`, DMD2, TDM): Generated acquisition with no runtime reward/advantage stage. DiffusionOPD supports ODE or SDE; DMD2 and TDM require ODE.
Mixing paradigms (e.g., using `ODE` dynamics with `GRPO`) will produce incorrect gradients silently.
@@ -75,7 +79,7 @@ offline epoch means one complete traversal of that resulting finite loader.
Checkpoints are written and read for **trainable members only** — components whose `target_module_map[name]` is non-empty (`adapter.trainable_component_names`). Frozen-but-shardable bundle members (e.g. Wan2.2's `transformer_2`, kept in `target_components` only to be FSDP-sharded for memory; see #9) map to `None` and are skipped by both `save_checkpoint` and `_load_lora`/`_load_full_model`. Loaders MUST iterate `trainable_component_names`, not `target_components`, or resume logs a spurious error for a per-component subdir that was never written. `resume_type='state'` restores via `accelerator.load_state` into the prepared bundle root and is therefore keyed to bundle membership — resuming into a different `target_components` / bundle composition will mismatch.
### 10. DeepSpeed ZeRO-3 Is Unsupported
-Supported distributed plans are DDP, FSDP, and DeepSpeed ZeRO-1/2. Reward model sharding under ZeRO-3 is broken even with DeepSpeed's own `zero.GatheredParameters` context manager, and parameter sharding also breaks frozen-component synchronization. `validate_supported_distributed_plan` (`trainers/abc.py`) rejects it at `BaseTrainer.__init__`, before any weights load, and `config/deepspeed/` ships no ZeRO-3 profile. Multi-role training narrows this further: `_validate_multirole_backend` requires ZeRO-1/2 and, under FSDP2, `use_orig_params=True`.
+Supported distributed plans are DDP, FSDP, and DeepSpeed ZeRO-1/2. Reward model sharding under ZeRO-3 is broken even with DeepSpeed's own `zero.GatheredParameters` context manager, and parameter sharding also breaks frozen-component synchronization. `validate_supported_distributed_plan` is defined in `trainers/multirole/backend.py`; `trainers/loader.py` calls it before model construction, while `BaseTrainer.__init__` repeats the check defensively. `config/deepspeed/` ships no ZeRO-3 profile. Multi-role training narrows this further: `_validate_multirole_backend` requires ZeRO-1/2 and, under FSDP2, `use_orig_params=True`. Muon narrows the plan independently to DDP/FSDP2 and requires a build exposing `torch.optim.Muon`; `validate_optimizer_backend_plan` rejects DeepSpeed, FSDP1, and an unavailable Muon API before weights load.
---
@@ -94,7 +98,14 @@ preparation, rewards, and advantage processing.
`optimize_batch()`. Online `DPOTrainer` forms pairs at `optimize()` entry. Offline DPO consumes
dataset pairs directly.
-**Trainer hierarchy**: New trainers MUST inherit directly from `BaseTrainer`. The only sanctioned exceptions are strict behavioral variants of GRPO that change only the per-step loss while reusing GRPO's sampling/advantage/eval machinery: `GRPOGuardTrainer → GRPOTrainer` (adds ratio-normalization) and `DPPOTrainer → GRPOTrainer` (replaces the PPO ratio-clip with a KL trust-region mask). Trainer-to-trainer inheritance creates fragile coupling; when in doubt, inherit from `BaseTrainer` and extract shared logic into helper methods. All reward-based trainers delegate advantage computation to `self.advantage_processor.compute_advantages()`; the distillation trainer `diffusion-opd` is the exception (its `prepare_feedback()` is a no-op with no reward/advantage stage).
+**Trainer hierarchy**: New trainers MUST inherit directly from `BaseTrainer`. The sanctioned
+existing behavioral extensions are `GRPOGuardTrainer → GRPOTrainer`, `DPPOTrainer → GRPOTrainer`,
+and `TDMR1Trainer → TDMTrainer`; TDM-R1 reuses TDM's deterministic trajectory and multi-role
+runtime while restoring runtime reward feedback. Trainer-to-trainer inheritance creates fragile
+coupling; when in doubt, inherit from `BaseTrainer` and extract shared logic into helper methods.
+Every runtime-reward trainer delegates advantage computation to `AdvantageProcessor`. Trainers
+with feedback `none` (`diffusion-opd`, DMD2, and TDM) bypass reward/advantage structurally; their
+no-op `prepare_feedback()` overrides are compatibility shims, not the execution mechanism.
### 12. BaseAdapter Abstract Methods
Subclasses of `BaseAdapter` MUST implement these **4 abstract methods**:
@@ -123,14 +134,20 @@ policy stays explicit at their semantic boundaries. Unsupported adapters declare
**Adapter hierarchy**: All model adapters MUST inherit directly from `BaseAdapter` — never from another adapter. Shared logic between adapters for the same model family should use private helper functions, code duplication, or mixins — not adapter-to-adapter inheritance. Adapter subclassing creates fragile coupling where changes to a parent adapter silently break child adapters, and makes the 4-abstract-method contract harder to verify (the 4 per-modality encoders have no-op defaults, so a fresh subclass of `BaseAdapter` is always valid; chained inheritance hides which encoder a model actually overrides).
### 13. BaseRewardModel Paradigm Split
-- `PointwiseRewardModel.__call__` receives batches of size `batch_size`, returns rewards of shape `(batch_size,)`
-- `GroupwiseRewardModel.__call__` receives all samples in a group (size `group_size`), returns rewards of shape `(group_size,)`
+- `PointwiseRewardModel.__call__` receives an applicable sub-batch of at most configured
+ `batch_size` and returns one reward per received sample.
+- `GroupwiseRewardModel.__call__` receives one complete applicable `unique_id` group and returns
+ one reward per group member in input order.
The `RewardProcessor` dispatches differently based on the model type. Do not change the calling convention.
### 14. Sample Dataclass Hierarchy
`BaseSample` → `T2ISample`, `ImageConditionSample`, `T2VSample`, `T2AVSample`, etc. The `_shared_fields` class variable determines which fields are NOT stacked across a batch. Incorrect `_shared_fields` causes silent data corruption during collation.
+`reconstruction_required_fields` separately names fields required to instantiate a concrete sample
+after a partial distributed gather, even when the downstream consumer did not request them. See
+[`topics/sample_lifecycle.md`](topics/sample_lifecycle.md#partial-gather-reconstruction).
+
**Two-layer hierarchy**: Task-level samples (`T2ISample`, `I2VSample`, `I2AVSample`, ...) are defined in `samples/samples.py` and inherit from `BaseSample` or its condition mixins (`ImageConditionSample`, `VideoConditionSample`). Model-specific samples (`LTX2Sample`, `LTX2I2AVSample`, ...) MUST inherit from the appropriate task-level sample — never from another model-specific sample across files. This mirrors the flat adapter hierarchy: `LTX2I2AVSample(I2AVSample)`, NOT `LTX2I2AVSample(LTX2Sample)`.
Legacy trajectory fields remain authoritative when `BaseSample.trajectory is None`. Structured
diff --git a/.agents/knowledge/docs_maintenance.md b/.agents/knowledge/docs_maintenance.md
index edcdee136..48df8cd3f 100644
--- a/.agents/knowledge/docs_maintenance.md
+++ b/.agents/knowledge/docs_maintenance.md
@@ -9,8 +9,8 @@
The knowledge system uses a 3-layer design with bidirectional cross-references:
```
-Root: AGENTS.md — project identity, behavioral principles (one-liners)
-Tier 1: philosophy.md, constraints.md, — core thesis + concise indexes (always read)
+Root: AGENTS.md — project identity and universal operating guide
+Tier 1: philosophy.md, constraints.md, — always-read current authority + concise indexes
architecture.md
Routing: README.md — trigger-based table from Tier 1 to Tier 2
Leaves: topics/*.md — self-contained detail (read when triggered)
@@ -19,7 +19,13 @@ Skills: skills/*/SKILL.md — workflows with downward refs to
## Node Roles
-**Non-leaf** (root + Tier 1): State core thesis in 1-3 lines, then index to leaf docs via tables or pointers. No inline explanations, no code examples, no checklists — those belong in leaves.
+**Non-leaf** (root + Tier 1): Keep universal operating rules and current architecture summaries
+concise, then route specialized detail to leaves. `AGENTS.md` may retain universal commands and the
+commit workflow; `constraints.md` may include the minimum rationale needed to make a hard rule
+enforceable; `architecture.md` may retain the current module graph, registries, extension points,
+and short design summaries. Model-specific recipes, deep implementation walkthroughs, new
+chronological fix records, and specialized checklists belong in leaves. Do not add Tier-1 detail
+when a leaf pointer is sufficient.
**Leaf** (`topics/*.md`): Self-contained, concise, essential knowledge. Include code refs, checklists, numbered gotchas. No filler prose, no introductory fluff, no restating what parent docs already say. Format examples: `adapter_conventions.md`, `train_inference_consistency.md`.
diff --git a/.agents/knowledge/topics/adapter_conventions.md b/.agents/knowledge/topics/adapter_conventions.md
index 595e281d5..3be52baa6 100644
--- a/.agents/knowledge/topics/adapter_conventions.md
+++ b/.agents/knowledge/topics/adapter_conventions.md
@@ -241,5 +241,6 @@ LTX2 packs `[video|audio]` into one `(B, Seq, C)` sequence, so it resolves as PA
## Cross-refs
-- UP: `architecture.md` "Adapter Pattern", `constraints.md` #5 #11-12
-- PEER: `train_inference_consistency.md`, `parity_testing.md`, `ff-new-model` Pitfall #6
+- UP: [`constraints.md` #5](../constraints.md#5-adapter-component-runtime-contract), [`constraints.md` #11](../constraints.md#11-basetrainer-execution-contract), [`constraints.md` #12](../constraints.md#12-baseadapter-abstract-methods), [Architecture Adapter Pattern](../architecture.md#adapter-pattern-models)
+- PEER: [Train/Inference Consistency](train_inference_consistency.md), [Parity Testing](parity_testing.md)
+- WORKFLOW: [`ff-new-model`](../../skills/ff-new-model/SKILL.md)
diff --git a/.agents/knowledge/topics/autocast_param_swap.md b/.agents/knowledge/topics/autocast_param_swap.md
index 6daea4265..746192a18 100644
--- a/.agents/knowledge/topics/autocast_param_swap.md
+++ b/.agents/knowledge/topics/autocast_param_swap.md
@@ -35,6 +35,6 @@ Ref/EMA/named snapshots share **one** model and swap in place via `EMAModuleWrap
## Cross-refs
-- `constraints.md` #20a, #20, #10
-- `topics/dtype_precision.md`, `train_inference_consistency.md`
-- `models/abc.py` `use_ref_parameters` / `use_ema_parameters` / `use_named_parameters`; `ema/ema.py` `EMAModuleWrapper`
+- UP: [`constraints.md` #20a](../constraints.md#20a-autocast-weight-cache-must-not-span-a-forward), [`constraints.md` #20](../constraints.md#20-mixed-precision-consistency), [`constraints.md` #10](../constraints.md#10-deepspeed-zero-3-is-unsupported)
+- PEER: [Dtype and Precision](dtype_precision.md), [Train/Inference Consistency](train_inference_consistency.md)
+- CODE: [`BaseAdapter` parameter-swap contexts](../../../src/flow_factory/models/abc.py), [`EMAModuleWrapper`](../../../src/flow_factory/ema/ema.py)
diff --git a/.agents/knowledge/topics/component_runtime.md b/.agents/knowledge/topics/component_runtime.md
index 06729b22d..a93ca6783 100644
--- a/.agents/knowledge/topics/component_runtime.md
+++ b/.agents/knowledge/topics/component_runtime.md
@@ -70,6 +70,25 @@ instance attribute.
- A target-owned composite root may still contain frozen auxiliary siblings. Pseudo runtimes move
only that remainder and exclude every prepared target route.
+## Activation-checkpoint ownership
+
+`configure_checkpointing_backend_plan()` resolves one owner before model loading; direct trainer
+construction repeats the same plan defensively after adapter realization.
+
+| FSDP plan | Activation-checkpoint owner |
+|---|---|
+| FSDP1 with train-level model checkpointing | Model-side owner; a simultaneously enabled backend owner is disabled |
+| FSDP1 + TDM-R1 | Both owners are disabled because reference/snapshot forwards invalidate FSDP1 recomputation |
+| FSDP2 with no train-level model policy | The configured backend policy is retained; otherwise checkpointing stays off |
+| FSDP2 with a full train-level policy | Accelerate/FSDP2 backend wrappers own recomputation; the train-level model policy is disabled |
+| FSDP2 with a selective train-level policy | Rejected because backend wrappers cannot preserve selective model boundaries |
+| FSDP2 with an adapter-owned in-forward capability | The adapter installs block-local boundaries after the FSDP input cast; the duplicate plugin owner is disabled during preparation |
+
+MiniMax H3 Ref2VA currently owns the adapter-specific exception. Its prepared FSDP units also
+receive a replay-time public `unshard()` hook for the affected PyTorch lifecycle, where a second
+checkpoint graph can replay after another graph resharded the same unit. These flags are
+model-specific memory policy and must not be copied to another adapter without evidence.
+
## Fix records
### Repeated-block metadata must survive the distributed bundle boundary
@@ -107,8 +126,9 @@ instance attribute.
- [ ] `adapter.pipeline`, `adapter.scheduler`, and public lifecycle hooks remain compatible.
- [ ] Scheduler names equal `trajectory_component_order`.
- [ ] Distributed preparation still routes through `ModelBundle`/`RoutedComponentProxy`.
+- [ ] Exactly one activation-checkpoint owner remains active for the selected FSDP plan.
## Cross-refs
- UP: [`constraints.md` #5](../constraints.md#5-adapter-component-runtime-contract), [`architecture.md` Component Management](../architecture.md#component-management)
-- PEER: [Structured Trajectory](structured_trajectory.md), [Component Variants](component_variants.md), [Adapter Conventions](adapter_conventions.md)
+- PEER: [Structured Trajectory](structured_trajectory.md), [Component Variants](component_variants.md), [Adapter Conventions](adapter_conventions.md), [MiniMax H3](minimax_h3.md)
diff --git a/.agents/knowledge/topics/dtype_precision.md b/.agents/knowledge/topics/dtype_precision.md
index 0280fdbd9..207d08cbc 100644
--- a/.agents/knowledge/topics/dtype_precision.md
+++ b/.agents/knowledge/topics/dtype_precision.md
@@ -96,8 +96,5 @@ The round-trip ensures that the precision of stored latents matches what trainin
## Cross-refs
-- `constraints.md` #18 (all-rank synchronization — precision errors may manifest differently per rank)
-- `constraints.md` #20 (mixed precision consistency)
-- `topics/autocast_param_swap.md` (#20a)
-- `train_inference_consistency.md` (log_prob mismatch from precision)
-- `topics/timestep_sigma.md` (scheduler math always float32)
+- UP: [`constraints.md` #18](../constraints.md#18-all-rank-synchronization-points), [`constraints.md` #20](../constraints.md#20-mixed-precision-consistency)
+- PEER: [Autocast and Parameter Swaps](autocast_param_swap.md), [Train/Inference Consistency](train_inference_consistency.md), [Timestep and Sigma](timestep_sigma.md)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index b5dd4c067..198877972 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -742,6 +742,5 @@ Based on the fix type, write the fix entry to the appropriate document:
## Cross-refs
-- `constraints.md` (archival target for constraint violations)
-- `architecture.md` (archival target for data-flow misunderstandings)
-- `ff-debug/SKILL.md` Phase 5 (knowledge capture workflow)
+- UP: [Hard Constraints](../constraints.md), [Architecture](../architecture.md)
+- WORKFLOW: [`ff-debug` Phase 5](../../skills/ff-debug/SKILL.md#5-capture-the-fix)
diff --git a/.agents/knowledge/topics/minimax_h3.md b/.agents/knowledge/topics/minimax_h3.md
index d4f3048da..bde33e60c 100644
--- a/.agents/knowledge/topics/minimax_h3.md
+++ b/.agents/knowledge/topics/minimax_h3.md
@@ -143,11 +143,13 @@ element-weighted reducer.
## Verification boundary
All workflows have pinned API/schema/no-weight verification and local offline codec/forward
-coverage. T2VA additionally completed
-real-weight LoRA rollout, decode, reward, replay, backward, checkpoint, and resume tests on one
-GPU and with FSDP2 on 16 GPUs. The native-resolution path completed initialization, checkpoint,
-decode, and evaluation. FL2VA/Ref2VA SFT and offline DPO still require the documented real-weight
-GPU matrix. Do not claim long-run reward improvement, convergence, or numerical parity.
+coverage. T2VA additionally completed real-weight LoRA rollout, decode, reward, replay, backward,
+checkpoint, and resume tests on one GPU and with FSDP2 on 16 GPUs. The native-resolution path
+completed initialization, checkpoint, decode, and evaluation. The
+[PR #220 matrix](../../../guidance/gpu_validation.md#pr-220-result) then completed all 36 H3
+real-weight smoke cells: T2VA, FL2VA, and Ref2VA across DDP/DeepSpeed ZeRO-2/FSDP2 and
+GRPO/SFT/offline DPO/TDM. The FL2VA first-plus-last SFT/offline-DPO variant gate also passed. Do
+not claim long-run reward improvement, convergence, quality parity, or numerical parity.
## Upgrade checklist
@@ -157,9 +159,10 @@ GPU matrix. Do not claim long-run reward improvement, convergence, or numerical
- [ ] Run H3 scheduler/runtime/registry/reference tests in the pinned environment.
- [ ] Parse all H3 examples through `Arguments.load_from_yaml`.
- [ ] Run the T2VA output-codec and common SFT/offline-DPO structured-state tests.
-- [ ] Rerun the documented T2VA real-weight smoke before changing support or memory claims.
+- [ ] Rerun the affected H3 cells in the documented real-weight matrix before changing support or
+ memory claims.
## Cross-refs
-- UP: [`constraints.md` #5](../constraints.md#5-adapter-component-runtime-contract), [`constraints.md` #14](../constraints.md#14-sample-dataclass-hierarchy), [`architecture.md` MiniMax H3](../architecture.md#minimax-h3)
+- UP: [`constraints.md` #5](../constraints.md#5-adapter-component-runtime-contract), [`constraints.md` #14](../constraints.md#14-sample-dataclass-hierarchy), [`architecture.md` registered components](../architecture.md#registered-components)
- PEER: [Component Runtime](component_runtime.md), [Structured Trajectory](structured_trajectory.md), [Parity Testing](parity_testing.md)
diff --git a/.agents/knowledge/topics/parity_testing.md b/.agents/knowledge/topics/parity_testing.md
index a14b8d1e4..f639092dd 100644
--- a/.agents/knowledge/topics/parity_testing.md
+++ b/.agents/knowledge/topics/parity_testing.md
@@ -58,5 +58,5 @@ def compare_tensors(name: str, a: torch.Tensor, b: torch.Tensor, atol: float = 1
## Cross-refs
-- `adapter_conventions.md` (inference/forward identity, upstream alignment rules)
-- `dtype_precision.md` (tensor dtype for comparison tolerance)
+- UP: [`constraints.md` #12](../constraints.md#12-baseadapter-abstract-methods), [Architecture Adapter Pattern](../architecture.md#adapter-pattern-models)
+- PEER: [Adapter Conventions](adapter_conventions.md), [Dtype and Precision](dtype_precision.md)
diff --git a/.agents/knowledge/topics/sample_lifecycle.md b/.agents/knowledge/topics/sample_lifecycle.md
index c4e59c456..4a0f4a3d8 100644
--- a/.agents/knowledge/topics/sample_lifecycle.md
+++ b/.agents/knowledge/topics/sample_lifecycle.md
@@ -102,11 +102,21 @@ Effect of the offload pipeline: `sample.to('cpu')` and `sample.to(device)` both
If a future custom adapter stores large GPU tensors in `extra_kwargs`, either handle them adapter-side or refactor `BaseSample.to` to delegate to `move_tensors_to_device(value, device, max_depth=1)` in an independent PR (note: that refactor will start moving `extra_kwargs['advantage']` together with the sample, which is benign for the current data flow but is a contract change).
+## Partial-gather reconstruction
+
+`gather_samples()` may transport only fields requested by a distributed consumer, then reconstruct
+the original concrete `BaseSample` subclass. A field that is required by the concrete class's
+`__post_init__`, identity normalization, or constructor invariant must therefore be included in the
+class-level `reconstruction_required_fields`, even when the reward or trainer does not consume it.
+Always union with the inherited set; `OrderedReferenceConditionSample` adds
+`reference_manifest` this way.
+
+This contract is independent of both `_shared_fields` (batch collation) and a reward model's
+`required_fields` (distributed groupwise consumer data). Add a regression that gathers a narrower
+field selection and proves the reconstructed concrete sample still carries every required field.
+
## Cross-refs
-- `constraints.md` #11 (BaseTrainer hook order: `sample()` → `prepare_feedback()` → `optimize()`)
-- `constraints.md` #14 (BaseSample dataclass hierarchy and `_shared_fields`)
-- `constraints.md` #15 + `.cursor/rules/examples-yaml-sync.mdc` (the three-tier strategy is an intentional deviation)
-- `topics/train_inference_consistency.md` item #4 (EMA swap without restore — preserved by per-batch interleave)
-- `topics/dtype_precision.md` (device-move never changes dtype; orthogonal to autocast)
-- `topics/autocast_param_swap.md` (#20a)
+- UP: [`constraints.md` #11](../constraints.md#11-basetrainer-execution-contract), [`constraints.md` #14](../constraints.md#14-sample-dataclass-hierarchy), [`constraints.md` #15](../constraints.md#15-pydantic-hparams-synchronization)
+- PEER: [Train/Inference Consistency](train_inference_consistency.md), [Dtype and Precision](dtype_precision.md), [Autocast and Parameter Swaps](autocast_param_swap.md)
+- RULE: [Examples YAML Synchronization](../../../.cursor/rules/examples-yaml-sync.mdc)
diff --git a/.agents/knowledge/topics/samplers.md b/.agents/knowledge/topics/samplers.md
index c53b0e546..7e341de18 100644
--- a/.agents/knowledge/topics/samplers.md
+++ b/.agents/knowledge/topics/samplers.md
@@ -332,6 +332,4 @@ data:
## Cross-refs
-- `constraints.md` #9, #9a (accelerator prepare scope, sampler geometric constraints)
-- `architecture.md` "Execution Pipelines" (generation acquisition)
-- `architecture.md` "Advantage Computation" (communication path depends on sampler type)
+- UP: [`constraints.md` #9](../constraints.md#9-accelerator-prepare-scope), [`constraints.md` #9a](../constraints.md#9a-sampler-geometric-constraints), [Architecture Execution Pipelines](../architecture.md#execution-pipelines), [Architecture Advantage Computation](../architecture.md#advantage-computation)
diff --git a/.agents/knowledge/topics/timestep_sigma.md b/.agents/knowledge/topics/timestep_sigma.md
index 8abbe5544..4a87b790f 100644
--- a/.agents/knowledge/topics/timestep_sigma.md
+++ b/.agents/knowledge/topics/timestep_sigma.md
@@ -44,6 +44,5 @@ Throughout the codebase, two related but distinct scales are used for time:
## Cross-refs
-- `constraints.md` #7 (coupled/decoupled paradigm — affects which timestep sampling is valid)
-- `topics/train_inference_consistency.md` (same `t` must produce same output in rollout vs training)
-- `topics/adapter_conventions.md` (adapter encapsulates timestep-to-model conversion)
+- UP: [`constraints.md` #7](../constraints.md#7-coupled-vs-decoupled-paradigm), [Architecture Timestep and Sigma Convention](../architecture.md#timestep--sigma-convention)
+- PEER: [Train/Inference Consistency](train_inference_consistency.md), [Adapter Conventions](adapter_conventions.md)
diff --git a/.agents/knowledge/topics/train_inference_consistency.md b/.agents/knowledge/topics/train_inference_consistency.md
index fda66c548..4894632be 100644
--- a/.agents/knowledge/topics/train_inference_consistency.md
+++ b/.agents/knowledge/topics/train_inference_consistency.md
@@ -67,6 +67,5 @@ If rollout and training `forward()` diverge, `ratio` deviates from 1.0 at epoch
## Cross-refs
-- `constraints.md` #7 (coupled/decoupled paradigm)
-- `dtype_precision.md` (precision boundaries, cast_latents)
-- `adapter_conventions.md` (inference/forward identity rule)
+- UP: [`constraints.md` #7](../constraints.md#7-coupled-vs-decoupled-paradigm), [Architecture Execution Pipelines](../architecture.md#execution-pipelines)
+- PEER: [Dtype and Precision](dtype_precision.md), [Adapter Conventions](adapter_conventions.md)
diff --git a/.cursor/rules/agents-docs-maintenance.mdc b/.cursor/rules/agents-docs-maintenance.mdc
index 1aeb85e9c..19907717a 100644
--- a/.cursor/rules/agents-docs-maintenance.mdc
+++ b/.cursor/rules/agents-docs-maintenance.mdc
@@ -10,7 +10,7 @@ The `.agents/knowledge/` system uses layered design with cross-references. Follo
## Layered structure
-- **Non-leaf** (root, Tier 1): State thesis in 1-3 lines, then index to leaf docs via tables/pointers. No inline explanations, code examples, or checklists — those live in leaves.
+- **Non-leaf** (root, Tier 1): Keep universal operating rules and current architecture summaries concise, then index specialized detail via tables/pointers. `AGENTS.md` may retain universal commands and the commit workflow; `constraints.md` may include the minimum rationale needed to enforce a hard rule; `architecture.md` may retain the current module graph, registries, extension points, and short design summaries. Model-specific recipes, deep implementation walkthroughs, new chronological fix records, and specialized checklists live in leaves.
- **Leaf** (`topics/*.md`): Self-contained, concise detail. Include code refs, checklists, gotchas. No filler prose, no restating parent content.
- No duplication across layers. If detail exists in a leaf, the parent points to it.
diff --git a/AGENTS.md b/AGENTS.md
index ce79bf0e8..2ebfbf74d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@
Flow-Factory is a unified **online and offline fine-tuning framework** for diffusion/flow-matching models. It provides a modular architecture where trainers, model adapters, data acquisition, and reward models are independently extensible through typed contracts and registries.
- **Algorithms**: SFT, offline DPO, online DPO, GRPO, GRPO-Guard, DPPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1
-- **Models**: FLUX.1 (+Kontext), FLUX.2 (+Klein), SD3.5, Qwen-Image (+Edit-Plus), Z-Image, Wan2 (T2V/I2V), LTX2 (T2AV/I2AV), Bagel, SenseNova-U1 (1.0/1.5; T2I + ordered multi-reference I2I)
+- **Models**: FLUX.1 (+Kontext), FLUX.2 (+Klein), SD3.5, Qwen-Image (+Edit-Plus), Z-Image, Wan2 (T2V/I2V), LTX2 (T2AV/I2AV), MiniMax H3 (T2VA/FL2VA/Ref2VA), Bagel, SenseNova-U1 (1.0/1.5; T2I + ordered multi-reference I2I)
- **Rewards**: PickScore (+Rank), CLIP, CLAP, ImageBind, OCR, GenEval/GenEval2, HPSv2, VLM-Evaluate, rational-rewards, and custom rewards
- **Python**: >=3.10 | **PyTorch**: >=2.6.0 | **License**: Apache-2.0
@@ -23,8 +23,8 @@ On session start, read **Tier 1** (see `.agents/knowledge/README.md`):
## Core Operating Principles
1. **Constraints first** — Read `constraints.md` + `architecture.md` before changes; search codebase before attempting fixes.
-2. **Cross-component awareness** — Changes to `abc.py` affect ALL subclasses; verify across algorithms (GRPO + NFT/AWM).
-3. **Plan before implement** — Multi-file tasks -> TodoWrite. Plan must state which skills apply.
+2. **Cross-component awareness** — Changes to base classes or typed contracts affect every registry-resolved implementation; verify the affected coupled-reward, decoupled-reward, no-feedback, and dataset-acquisition paths.
+3. **Plan before implement** — Multi-file tasks require an explicit task plan using the agent's supported planning mechanism. The plan must state which skills apply.
4. **Challenge first, execute second** — Spot logic flaws or simpler alternatives? Raise before executing.
5. **Escalation** — After three failed approaches, document findings and request review.
6. **Fix capture** — After every bug fix, generate summary per `.agents/knowledge/topics/fix_patterns.md` template.
From 002df5f2b1d3f08087f0ebfd7bdbb864fcf9ad9e Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 07:53:10 +0800
Subject: [PATCH 71/76] [agents] docs: upgrade framework development skills
---
.agents/skills/ff-debug/SKILL.md | 263 ++++++++-------
.agents/skills/ff-develop/SKILL.md | 253 +++++++++-----
.agents/skills/ff-new-algorithm/SKILL.md | 413 +++++++++++------------
.agents/skills/ff-new-model/SKILL.md | 295 +++++++++-------
.agents/skills/ff-new-reward/SKILL.md | 174 +++++-----
.agents/skills/ff-review/SKILL.md | 270 +++++++++------
6 files changed, 941 insertions(+), 727 deletions(-)
diff --git a/.agents/skills/ff-debug/SKILL.md b/.agents/skills/ff-debug/SKILL.md
index b98ef698b..bc5d3f9cc 100644
--- a/.agents/skills/ff-debug/SKILL.md
+++ b/.agents/skills/ff-debug/SKILL.md
@@ -1,128 +1,153 @@
---
name: ff-debug
-description: "Bug fixing and debugging for ANY error, crash, loss divergence, gradient explosion, distributed hang, NaN, or unexpected behavior. Covers quick fixes and full protocol with 5-phase investigation. Trigger: 'fix bug', 'fix error', 'broken', 'crash', 'doesn't work', 'fails with', 'loss NaN', 'training hangs', 'OOM'."
+description: "Debug Flow-Factory crashes, hangs, OOMs, numerical failures, finite-loader errors, component routing, distributed preparation, optimizer roles, and checkpoint/resume mismatches. Use for bug fixing or unexpected training behavior."
---
# Debug Workflow
-## Related Topics (read for numerical / consistency issues)
-
-- NaN, loss divergence, wrong gradients -> `topics/train_inference_consistency.md`
-- Dtype mismatch, overflow, precision -> `topics/dtype_precision.md`
-- Frozen/flat loss or KL ≈ 0 -> `topics/autocast_param_swap.md` (#20a)
-
-## Two Pathways
-
-### Quick Path (obvious root cause)
-
-Use when: Error message clearly points to the issue (typo, missing import, wrong type).
-
-1. Reproduce the error
-2. Check `.agents/knowledge/constraints.md` for relevant constraints
-3. Write targeted fix
-4. Verify with test
-5. Run `/ff-review`, commit
-
-If not resolved in 15 min -> switch to Full Protocol.
-
-### Full Protocol (complex issues)
-
-Use when:
-- Distributed training bugs (deadlocks, rank mismatches)
-- Numerical issues (NaN, loss divergence, wrong gradients)
-- Silent failures (training runs but produces garbage)
-- Multiple failed fix attempts
-
-## Full Protocol — Five Phases
-
-### Phase 1: Root Cause Investigation
-
-1. **Read complete error messages** — Full stack traces matter, don't skim
-2. **Consult constraints** — Check `.agents/knowledge/constraints.md`
-3. **Reproduce consistently** — Isolate the exact trigger condition
-4. **Trace execution path** — Follow through the 6-stage pipeline
-5. **Check recent changes** — `git log --oneline -10` — what changed recently?
-
-#### Distributed-Specific Checklist
-- Does the error appear on all ranks or just one?
-- Is `accelerator.wait_for_everyone()` missing before the failure point?
-- Are frozen components synchronized across ranks? (Constraint #19)
-- Is ZeRO-3 being used? (Constraint #10 — unsupported)
-
-### Phase 2: Pattern Analysis
-
-1. **Find working examples** — Compare with a similar model/algorithm that works
-2. **Diff analysis** — What's different between working and broken paths? Compare **completely** — diff line by line, not skim. Include config YAML and environment vars.
-3. **Isolate variables** — Change one thing at a time
-4. **Check dependencies** — Different diffusers version? Different PyTorch version?
-
-### Phase 3: Hypothesis Testing
-
-1. **One hypothesis per iteration** — Formulate a single falsifiable hypothesis
-2. **Minimal test case** — Reproduce with smallest possible config
-3. **Low confidence (<80%)?** — Add debug logging before applying fix
-
-**Red flags — STOP and restart from Phase 1:**
-- "Let me just try changing X and see what happens"
-- "Quick fix for now, clean up later"
-- "It probably works, let me move on"
-
-**Verification gate** — before acting on a conclusion, check:
-- Does the evidence actually support this cause, or just correlate?
-- Could a different root cause produce the same symptoms?
-- What observation would disprove this hypothesis? Have you looked for it?
-
-### Phase 4: Fix Implementation
-
-1. **Write failing test first** (if possible)
-2. **Implement targeted fix** — Only fix the bug, don't refactor
-3. **Check cross-algorithm impact** — Does this fix break GRPO? NFT? AWM?
-4. **Check cross-model impact** — Test with at least two model adapters
-5. Before committing: run `/ff-review` skill.
-
-### Phase 5: Knowledge Capture
-
-After fix is verified:
-- Update `constraints.md` if a new constraint was discovered
-- Add regression test if applicable
-- Document the root cause in the commit message
-- Follow fix archival process in `topics/fix_patterns.md`
+## Load the Relevant Contracts
+
+Always read Tier 1: `../../knowledge/constraints.md`, `../../knowledge/architecture.md`, and
+`../../knowledge/philosophy.md`. Then route by symptom:
+
+| Symptom or area | Also read |
+|---|---|
+| Wrong gradients, ratio drift, bad output | `../../knowledge/topics/train_inference_consistency.md`, `../../knowledge/topics/parity_testing.md` |
+| Dtype mismatch, overflow, flat loss or KL | `../../knowledge/topics/dtype_precision.md`, `../../knowledge/topics/autocast_param_swap.md` |
+| Missing component, wrong device, lazy load, wrap/OOM | `../../knowledge/topics/component_runtime.md` |
+| Multi-component rollout or replay | `../../knowledge/topics/structured_trajectory.md` |
+| Variant, role cadence, optimizer group, Muon, role checkpoint | `../../knowledge/topics/component_variants.md` |
+| Finite dataset, target encoding, SFT/offline DPO | `../../../guidance/workflow.md`, `../../../guidance/datasets.md` |
+
+## Classify the Execution Path First
+
+Resolve the trainer and its algorithm-specific `TrainingArguments`; their immutable
+`ExecutionContract` values must be equal.
+
+| Composition | Expected path |
+|---|---|
+| `generation + runtime_reward` | `sample()` -> feedback/reward -> advantage -> `optimize()` |
+| `generation + none` | `sample()` -> `optimize()`; no training reward/advantage stage |
+| `dataset + none` | Exhaust one official finite `DistributedSampler` loader through `optimize_batch()` |
+
+Do not infer execution mode from batch fields. Track `optimizer_step` independently from
+`rollout_iteration` or `data_epoch`; a dataset epoch advances only after clean loader exhaustion.
+
+## Quick Path
+
+Use when the failure is deterministic and the stack trace identifies one local contract breach.
+
+1. Reproduce it with the smallest representative test or config.
+2. Trace the owning boundary and relevant constraint.
+3. Add a regression that fails for the same reason.
+4. Apply the narrow fix and run affected contract tests.
+5. Run `/ff-review` before committing.
+
+If the cause is uncertain, distributed, numerical, or survives one focused attempt, use the full
+protocol.
+
+## Full Protocol
+
+### 1. Establish the Failure Boundary
+
+- Read every rank's complete traceback and first causal error.
+- Record the resolved trainer, adapter, execution contract, model I/O contract, backend, optimizer
+ types, finetune type, dtype policy, and checkpoint mode.
+- Compare against a working path one variable at a time, including YAML and backend config.
+- Identify when failure occurs: preflight, native component load, preprocessing, bundle prepare,
+ proxy-routed forward, acquisition, optimizer step, save, or resume.
+- Check recent changes with a focused file/commit diff; do not assume temporal correlation is cause.
+
+### 2. Check Ownership Invariants
+
+#### Dataset acquisition
+
+- Uses PyTorch's official `DistributedSampler`, even at one rank, and calls
+ `set_epoch(data_epoch)`.
+- Uses explicit positive `gradient_accumulation_steps`; rank-local batch count closes every
+ accumulation window without an implicit flush.
+- Caches prompt/input conditions only. Target, chosen, and rejected media is decoded and encoded on
+ demand.
+- Calls `prepare_condition_state()` once per batch. Offline DPO reuses that object, schedule, noise,
+ and one reference scope across both arms.
+- Applies the adapter's complete `offline_training_forward_overrides`, not rollout CFG semantics.
+
+#### Component runtime and loading
+
+- Resolve membership with `has_component`, `get_component`, or `_require_component`, never
+ `hasattr(adapter, name)`.
+- Keep canonical components, prepared/replacement overrides, declared specs, materialized modules,
+ optional `None`, and pseudo aliases distinct.
+- `materialize_components(None)` means already-materialized modules, not all lazy declarations.
+- Trace logical names to physical roots through `ModelLoadCoordinator`; adapters/trainers must not
+ reproduce FSDP loading state or broadcast weights directly.
+- All target and frozen-but-shardable routes enter one `ModelBundle` with one optimizer prepare
+ root. Adapter forwards after prepare route through `RoutedComponentProxy`.
+- For FSDP OOM, inspect bundle-exposed `_no_split_modules`/`_repeated_blocks`, wrap classes, adapter
+ memory capabilities, checkpoint replay, unshard stream, and backward prefetch before shrinking
+ the workload.
+
+#### Variants, optimizers, and distributed plans
+
+- A temporal reference/EMA/snapshot is not a live component variant.
+- Variants are declared before prepare; role parameter and optimizer-group ownership is disjoint and
+ exhaustive.
+- Do not assume one group per role: Muon uses matrices plus an optional AdamW remainder inside one
+ `CompositeOptimizer` root.
+- Reject ZeRO-3. Reject Muon before model loading when its PyTorch API is absent or the backend is
+ DeepSpeed/FSDP1. Multi-role DeepSpeed requires ZeRO-1/2. Multi-role FSDP2 requires
+ `use_orig_params=True`; after prepare, registry and optimizer references must point to the
+ DTensor-backed parameters owned by the prepared model root.
+- Activation checkpointing has one owner. FSDP2 full policy is normalized to backend ownership;
+ selective model checkpointing is rejected. Inspect adapter-owned in-forward checkpointing only
+ when the adapter explicitly opts in.
+
+#### Checkpoint and exact resume
+
+- Model-only export scope and resumable state scope are different. Resumable multi-role saves include
+ training-only roles, role counters, optimizer ownership, and variant snapshots.
+- Validate variant/runtime metadata before Accelerate mutates prepared state.
+- Exact identity covers changed objective, model/backend/optimizer semantics, realized data order,
+ and replayed evaluation configuration; cadence and resume location remain operational controls.
+- Preserve all-rank phase symmetry and atomic publication ordering.
+
+#### Numerical and reward paths
+
+- Wrap each policy/reference/EMA forward in its own autocast region when weights can change in place.
+- Consume trajectories only through adapter bridge methods and authoritative component order.
+- A partial cross-rank gather must union the concrete sample class's
+ `reconstruction_required_fields` before reconstruction. This is independent of reward
+ `required_fields` and collation `_shared_fields`.
+- Pointwise rewards return one finite value per actual input chunk, which may be smaller than
+ `batch_size`; groupwise rewards preserve complete `unique_id` order.
+- Per-dataset reward applicability is framework-owned; model NaN/Inf is an error, not a routing
+ sentinel.
+
+### 3. Test One Falsifiable Hypothesis
+
+State the proposed cause, the observation that would disprove it, and the smallest experiment that
+separates it from alternatives. Add instrumentation when confidence is below 80%. Avoid speculative
+fallbacks or several behavioral changes in one experiment.
+
+### 4. Fix and Verify in Scope
+
+- Write the regression first when practical.
+- Fix the authoritative owner rather than patching downstream consumers.
+- Verify the narrow unit contract, then affected compositions:
+ - execution kernel: GRPO, a reward-free generation trainer, and SFT/offline DPO as applicable;
+ - adapter/trajectory: legacy single-component and structured multimodal paths;
+ - runtime/loading: the affected classic/modular/pseudo runtime and supported backends;
+ - variants/optimizer: single-role AdamW plus multi-role/Muon cases when touched;
+ - checkpoint: model-only round trip and exact-state resume when touched.
+- Test at least two adapters only when the changed abstraction is shared across adapters.
+
+### 5. Capture the Fix
+
+Follow `../../knowledge/topics/fix_patterns.md`: record symptom, root cause, fix, lesson, related
+constraint, test evidence, and commit. Update Tier 1 only when the fix establishes a durable
+cross-module invariant.
## Three-Strike Rule
-If the same approach fails three times:
-1. **HALT** all fix attempts
-2. Question whether the underlying approach/architecture is wrong
-3. Step back and re-examine: are you solving the right problem?
-4. Report to user with analysis before continuing
-
-## Common Issue Categories
-
-### Training Loop Issues
-- [ ] Stage ordering violated? (Constraint #6)
-- [ ] Coupled/decoupled paradigm mismatch? (Constraint #7)
-- [ ] Component not on correct device? (Constraint #8)
-- [ ] Dataloader incorrectly prepared via accelerator? (Constraint #9)
-
-### Model Adapter Issues
-- [ ] `load_pipeline()` returning wrong type? (Constraint #5)
-- [ ] `target_module_map` mapping incorrect components?
-- [ ] `_shared_fields` causing data corruption? (Constraint #14)
-- [ ] Preprocessing modules not offloaded after Stage 1?
-
-### Reward Issues
-- [ ] Pointwise/Groupwise confusion? (Constraint #13)
-- [ ] Wrong reward shape returned?
-- [ ] `required_fields` not set correctly?
-- [ ] Device mismatch between reward model and generated samples?
-
-### Configuration Issues
-- [ ] YAML key doesn't match the dataclass field name? (Constraint #17)
-- [ ] Algorithm-specific args using wrong subclass? (Constraint #16)
-- [ ] Registry key doesn't match? (Constraint #1)
-
-### Distributed Issues
-- [ ] Missing synchronization barrier? (Constraint #18)
-- [ ] FSDP frozen components uninitialized on Rank > 0? (Constraint #19)
-- [ ] Mixed precision casting order incorrect? (Constraint #20) — see also `topics/dtype_precision.md` for precision diagnosis checklist
-- [ ] Using ZeRO-3? (Constraint #10 — not supported)
+After three failed approaches to the same cause, stop patching, document evidence and rejected
+hypotheses, reassess the ownership model, and request review before continuing.
diff --git a/.agents/skills/ff-develop/SKILL.md b/.agents/skills/ff-develop/SKILL.md
index 59239311a..f9e2cc2f5 100644
--- a/.agents/skills/ff-develop/SKILL.md
+++ b/.agents/skills/ff-develop/SKILL.md
@@ -1,98 +1,167 @@
---
name: ff-develop
-description: "Feature development with cross-module impact analysis. Covers trainer hierarchy, model adapters, reward pipeline, config system, sample dataclasses, and distributed training paths. Trigger: 'add feature', 'implement', 'refactor', 'reorganize', 'new capability'."
+description: "Develop or refactor Flow-Factory features with cross-module impact analysis across execution contracts, typed data/model I/O, component loading, prepared bundles, rewards, variants, optimizers, distributed backends, and checkpoints."
---
# Feature Development Workflow
-## Related Topics (read if your change touches these areas)
-
-- Adapter changes -> `topics/adapter_conventions.md`
-- Trainer/scheduler changes -> `topics/train_inference_consistency.md`
-- Trainer `optimize()` loop, autocast scope, ref/EMA/named param swaps -> `topics/autocast_param_swap.md`
-- Precision changes -> `topics/dtype_precision.md`
-
-## Impact Analysis Checklist
-
-Before implementing features or refactoring, analyze impacts across these areas:
-
-### 1. Trainer Hierarchy (`constraints.md` #11)
-- Changes to `BaseTrainer` affect all 9 concrete trainers (grpo, grpo-guard, dppo, nft, awm, dgpo, dpo, crd, diffusion-opd); changed abstract methods must be implemented on every one
-- Changes to `AdvantageProcessor` affect all reward-based trainers (`architecture.md` "Advantage Computation"; `diffusion-opd` skips it)
-- Check: Does your change alter `_initialization()`, `_init_reward_model()`, or `_init_dataloader()`?
-
-### 2. Model Adapter Hierarchy (`constraints.md` #12)
-- Changes to `BaseAdapter` affect ALL model adapters
-- Check: Does your change modify component management, LoRA logic, or mode switching?
-- **Adding a new modality** (e.g. audio): prefer non-abstract no-op default + opt-in override (R7 pattern). Don't add `@abstractmethod` to a new encoder; that forces stub edits on every existing concrete adapter. The 4 abstract methods (`load_pipeline`, `decode_latents`, `forward`, `inference`) are intentionally minimal — encoders are opt-in by modality.
-
-### 3. Reward Pipeline (`constraints.md` #13)
-- Changes to `BaseRewardModel` or `RewardProcessor` affect all reward models
-- Check: Does your change alter the Pointwise/Groupwise dispatch?
-
-### 4. Configuration System (`constraints.md` #15–17)
-- Check: Did you rename, remove, or **add** fields? ALL configs in `examples/` must be updated
-
-### 5. Sample Dataclasses (`constraints.md` #14)
-- Changes to `BaseSample` or its subclasses affect data flow through all 6 stages
-- Check: Did you change `_shared_fields` or add new fields?
-
-### 6. Distributed Training Paths (`constraints.md` #9, #18–20)
-- Changes may behave differently under Accelerate vs DeepSpeed
-- Check: Does your change involve `accelerator.prepare()`, gradient accumulation, or model sharding?
-
-## Refactoring Safety Rules
-
-1. **Establish baseline** — Run tests before making changes
-2. **One at a time** — ONE structural change → update ALL callers → verify → commit
-3. **Never combine** — Don't combine multiple refactoring steps in one commit
-
-## Workflow Steps
-
-1. **Understand scope**
- - Read relevant `abc.py` base classes
- - Identify all affected subclasses and callers
- - Read related `guidance/` docs
-
-2. **Plan changes**
- - List all files that need modification
- - Document expected behavior changes
- - Identify test scenarios
-
-3. **Implement methodically**
- - Make ONE change at a time
- - Update ALL callers/subclasses
- - Run tests after each change
-
-4. **Cross-algorithm verification**
- - Test with GRPO (coupled paradigm; also covers GRPO-Guard / DPPO variants)
- - Test with NFT or AWM (decoupled paradigm; also DGPO / CRD / DPO)
- - If the change touches the sample/optimize path, also test `diffusion-opd` (distillation; no reward/advantage stage)
- - Verify with at least two different model adapters
-
-## Documentation
-
-Before committing, check if the change requires documentation updates:
-
-- **New/changed API** -> update relevant `guidance/` doc
-- **New/changed config fields** -> update ALL example configs in `examples/`
-- **Architecture change** -> update `.agents/knowledge/architecture.md`
-- **New constraint discovered** -> add to `.agents/knowledge/constraints.md`
-- **Bug fix experience?** -> follow `.agents/knowledge/topics/fix_patterns.md` archival process
-
-## When to Delegate
-
-- **Adding a new model** → `/ff-new-model`
-- **Adding a new reward** → `/ff-new-reward`
-- **Adding a new algorithm** → `/ff-new-algorithm`
-- **Debugging a bug** → `/ff-debug`
-- **Pre-commit review** → `/ff-review`
-
-## Pre-Commit Checks
-
-- [ ] Impact analysis completed for all 6 areas
-- [ ] All callers/subclasses updated
-- [ ] Tests pass
-- [ ] Code formatted with Black and isort
-- [ ] YAML configs in `examples/` updated: new fields added, renamed fields updated, removed fields cleaned up
-- [ ] License header present on new files
+## Read by Change Area
+
+Always read Tier 1. Add only the topic docs relevant to the change:
+
+| Change area | Read |
+|---|---|
+| Adapter semantics or parity | `../../knowledge/topics/adapter_conventions.md`, `../../knowledge/topics/parity_testing.md` |
+| Trainer forward/replay | `../../knowledge/topics/train_inference_consistency.md`, `../../knowledge/topics/autocast_param_swap.md` |
+| Dtype or mixed precision | `../../knowledge/topics/dtype_precision.md` |
+| Component discovery, lifecycle, load, prepare | `../../knowledge/topics/component_runtime.md` |
+| Multi-component rollout/replay | `../../knowledge/topics/structured_trajectory.md` |
+| Variants, roles, optimizers, role checkpoints | `../../knowledge/topics/component_variants.md` |
+| Dataset acquisition/offline objective | `../../../guidance/workflow.md`, `../../../guidance/datasets.md` |
+| Acceleration plugin | `../../../guidance/acceleration.md` |
+
+## Plan Around Ownership
+
+Before editing, state:
+
+- the authoritative owner and public contract being changed;
+- affected execution compositions (`generation + runtime_reward`, `generation + none`,
+ `dataset + none`);
+- registries, arguments, examples, docs, and checkpoint compatibility surfaces;
+- affected component runtime types and distributed backends;
+- unit, integration, and GPU evidence required.
+
+Prefer one coherent invariant per commit. A contract change may need code, tests, docs, examples, and
+knowledge updates atomically; do not split it merely to minimize file count.
+
+## Impact Analysis
+
+### 1. Execution Kernel and Trainers
+
+- Derive current trainers from both trainer and TrainingArguments registries; do not maintain a
+ hard-coded subclass list.
+- Trainer and algorithm-specific arguments declare the same immutable `ExecutionContract`.
+- `BaseTrainer.start()` owns acquisition dispatch, periodic boundaries, progress, and cycle hooks.
+ Generation implements `optimize(samples)`; dataset acquisition implements
+ `optimize_batch(batch)`.
+- Keep `optimizer_step`, `rollout_iteration`, and `data_epoch` independent. Exact runtime identity
+ must include any changed objective, data, backend, optimizer, or replayed evaluation semantics.
+- New trainers inherit directly from `BaseTrainer`. Existing sanctioned strict extensions are
+ GRPO-Guard/DPPO from GRPO and TDM-R1 from TDM; another trainer-to-trainer extension requires an
+ explicit architectural justification.
+
+### 2. Data Acquisition and Schemas
+
+- Generation uses framework grouped loaders and adapter inference. Dataset acquisition uses an
+ official finite `DistributedSampler`, calls `set_epoch(data_epoch)`, and does not prepare its
+ training loader through Accelerator.
+- Dataset source weights stay `1`; gradient accumulation is explicit and every rank-local epoch
+ closes cleanly without an implicit partial-window flush.
+- Strict V2 supervision records and collators remain model-neutral. Cache prompt/input conditions,
+ never target/chosen/rejected pixels or latents.
+- Evaluation remains generation-based, including for SFT and offline DPO.
+
+### 3. Adapter and Model I/O Contracts
+
+- Preserve the four abstract adapter methods and opt-in modality encoders. New sample classes use
+ the task-level hierarchy, not another model-specific class.
+- Assess the immutable `PipelineIOContract`, checkpoint-realized specialization, condition-state
+ preparer, output-state codec, exact geometry validation, offline forward overrides, and objective
+ reduction whenever offline capability changes.
+- Public boundary-owning adapter wrappers validate shared semantics; extend their protected hooks
+ instead of overriding them.
+- Structured multimodal trajectories use adapter-owned component order and bridge APIs; trainer code
+ never branches on legacy vs structured storage.
+- Fields required by concrete sample reconstruction belong in inherited
+ `reconstruction_required_fields`; do not conflate this with reward `required_fields` or collator
+ `_shared_fields`.
+
+### 4. Component Runtime and Loading
+
+- Preserve canonical, override, declared, materialized, optional, and alias boundaries across
+ `ClassicPipelineRuntime`, `ModularPipelineRuntime`, and `PseudoPipelineRuntime`.
+- Resolve component membership through the runtime, not adapter attributes. Lazy materialization
+ names required components explicitly.
+- Public adapter lifecycle hooks remain the trainer-facing seam. `ModelLoadCoordinator` compiles
+ logical declarations into exactly-once physical-root ownership; trainer/model code does not
+ duplicate backend loading policy.
+- Loading dtype is applied during native materialization; frozen/trainable dtype policy also reaches
+ components materialized later.
+
+### 5. Prepared Model Ownership
+
+- All target components, frozen-but-shardable siblings, and live variant routes form one
+ `ModelBundle`; prepare it with one optimizer root.
+- After prepare, canonical adapter forwards route through `RoutedComponentProxy`. Preserve stable
+ member names and `_no_split_modules`/`_repeated_blocks` metadata needed by FSDP policy discovery.
+- Do not manually move, wrap, or offload a prepared route or individual trainable variant.
+- Checkpoint save/load iterates trainable component ownership symmetrically; frozen bundle members
+ are not given nonexistent per-component artifacts.
+
+### 6. Variants, Roles, Optimizers, and Resume
+
+- Spatial live trainable copies use `ComponentVariantRegistry`; temporal references, EMA, and old
+ policies use named/ref snapshots.
+- Algorithms own role names and update cadence. Variants are declared before prepare; role
+ parameter and optimizer-group ownership is disjoint and exhaustive.
+- The top-level `optimizers:` list has one entry per trainable role. Do not assume one group per
+ role: Muon splits matrices from an optional AdamW remainder and still participates in one
+ `CompositeOptimizer` root.
+- Muon is supported only when `torch.optim.Muon` exists and the backend is DDP or FSDP2. Multi-role
+ DeepSpeed requires ZeRO-1/2. Multi-role FSDP2 requires `use_orig_params=True`; the registry is
+ rebound after prepare and optimizer references must point to the prepared root's DTensor-backed
+ parameters.
+- Resumable checkpoints include training-only roles, role counters, optimizer layout, and variant
+ snapshots; validate metadata before prepared state mutation.
+
+### 7. Rewards, Advantages, and Acceleration
+
+- Pointwise/groupwise routing, per-dataset applicability, async execution, and train/eval model
+ deduplication are shared reward contracts.
+- `feedback=none` bypasses training reward/advantage structurally; do not emulate it with incidental
+ no-op overrides. Evaluation rewards remain independently configurable.
+- Reward-based algorithms delegate advantage communication to `AdvantageProcessor`.
+- Acceleration entries preserve ordered application and declared safety/stage. Lossy rollout-only
+ acceleration is incompatible with coupled trainers.
+
+### 8. Distributed Precision and Checkpointing
+
+- Validate unsupported backend and optimizer plans before model weights load. ZeRO-3 remains
+ unsupported.
+- Activation checkpointing has one owner. FSDP2 normalizes full model checkpointing to backend
+ ownership and rejects selective model policies; adapter-owned in-forward boundaries require an
+ explicit capability.
+- Wrap each forward in its own autocast region when optimizer steps or parameter swaps can occur.
+- Preserve synchronization at preprocessing, evaluation, checkpoint, and publication boundaries.
+
+## Implementation and Verification
+
+1. Establish a passing baseline for the affected tests.
+2. Change the authoritative contract and update all discovered callers/subclasses.
+3. Add focused invariant tests before broad integration tests.
+4. Verify only the affected matrix, choosing representatives from:
+ - coupled generated feedback: GRPO, plus GRPO-Guard/DPPO if loss behavior changed;
+ - decoupled generated feedback: online DPO or NFT/AWM/DGPO/CRD;
+ - generated no-feedback: DiffusionOPD or DMD2/TDM;
+ - dataset no-feedback: SFT and offline DPO;
+ - multi-role: DMD2/TDM/TDM-R1;
+ - legacy single-component and structured multimodal adapters.
+5. Cover DDP, ZeRO-2, and FSDP2 only where the changed abstraction reaches those backends. Add
+ Muon positive/negative coverage when optimizer selection is touched.
+6. Run `/ff-review` before commit.
+
+## Documentation and Examples
+
+- API/workflow changes update the matching `guidance/` document.
+- User-facing config fields update every affected example with defaults/options; example paths
+ follow `examples/{algorithm}/{finetune_type}/{model_type}/{variant}.yaml`.
+- Architecture changes update the appropriate knowledge layer. New durable invariants go in
+ `constraints.md`; detailed discoveries and fix history stay in topic docs.
+- Bug fixes follow `../../knowledge/topics/fix_patterns.md`.
+
+## Pre-Commit Gate
+
+- All affected registries, arguments, callers, tests, examples, and docs agree.
+- Changed Python files pass Black/isort; new source files carry the license header.
+- Public methods are typed and use English Google-style docstrings.
+- No silent fallback weakens a typed contract or ownership boundary.
diff --git a/.agents/skills/ff-new-algorithm/SKILL.md b/.agents/skills/ff-new-algorithm/SKILL.md
index ecb46f37f..b57a32366 100644
--- a/.agents/skills/ff-new-algorithm/SKILL.md
+++ b/.agents/skills/ff-new-algorithm/SKILL.md
@@ -1,266 +1,241 @@
---
name: ff-new-algorithm
-description: "Complete workflow for adding an online or offline training algorithm. Covers execution-contract and paradigm selection, TrainingArguments subclass, trainer implementation, registry, example config, and verification. Trigger: 'add algorithm', 'new trainer', 'new training method', 'implement algorithm'."
+description: "Add an online, offline, or distillation training algorithm to Flow-Factory. Use for a new trainer, objective, execution contract integration, or multi-role training method."
---
# New Training Algorithm Integration
-> **Authoritative reference**: `guidance/algorithms.md`
-
-## Prerequisites
-
-Determine your algorithm's characteristics:
-- **Acquisition**: Generated rollouts or a finite dataset? (`generation` / `dataset`)
-- **Feedback**: Runtime reward/advantage or none? (`runtime_reward` / `none`)
-- **Paradigm**: Coupled or Decoupled? (`constraints.md` #7)
-- **Dynamics**: Which SDE/ODE formulation? (`Flow-SDE`, `Dance-SDE`, `CPS`, `ODE`)
-- **Supervision**: Prompt-only generation, demonstrations, preference pairs, or a new typed record?
-- **Advantage**: If feedback is enabled, how are advantages computed? (Most reward-based algorithms can delegate to `AdvantageProcessor`)
-- **Loss**: What is the policy optimization objective?
-
-## Phase 1: Design
-
-1. **Study existing implementations**:
- - Coupled example: `trainers/rl/grpo.py` (GRPO)
- - Decoupled example: `trainers/rl/nft.py` (DiffusionNFT) or `trainers/rl/awm.py` (AWM)
- - Finite demonstration example: `trainers/offline/sft.py` (SFT)
- - Finite preference example: `trainers/offline/offline_dpo.py` (offline DPO)
-2. **Identify what's shared vs unique** (`constraints.md` #11):
- - Shared: the cycle loop (`BaseTrainer.start`), acquisition dispatch, progress counters,
- adapter interface, checkpoint/eval boundaries, role optimization, and exact-resume identity
- - Conditional: runtime rewards, `AdvantageProcessor`, `prepare_feedback`, and
- `compute_advantages` exist only when the feedback contract requests them
- - Unique: the loss function and the algorithm-specific hyperparameters. Never restate the loop
- - Generation hook order: `sample()` → optional `prepare_feedback()` → `optimize()`
- - Dataset hook order: official finite loader traversal → `optimize_batch(batch)`
-3. **Declare one immutable execution contract**:
- - Online RL: `ONLINE_EXECUTION_CONTRACT` (`generation + runtime_reward`)
- - Generation without rewards: `ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT`
- - Finite offline training: `OFFLINE_EXECUTION_CONTRACT` (`dataset + none`)
-
-Keep execution semantics orthogonal to `PipelineIOContract`. The algorithm owns how examples are
-acquired and optimized; the adapter owns accepted input/output media, geometry, and output-state
-encoding. A new offline record shape belongs in the typed data layer, never in model-specific loss
-branches.
-
-## Phase 2: Configuration
-
-### Step 1 — Define Algorithm-Specific Arguments
-
-Create a new file `src/flow_factory/hparams/training_args/my_algo.py`:
+Read `../../../guidance/algorithms.md`, `../../../guidance/workflow.md`, Tier 1, and
+`../../knowledge/topics/train_inference_consistency.md`. Also read
+`../../knowledge/topics/component_variants.md` for more than one live trainable copy and
+`../../../guidance/datasets.md` for dataset acquisition.
+
+## 1. Classify the Algorithm
+
+Decide these independent properties before writing code:
+
+- **Acquisition**: generated collection or finite dataset (`generation` / `dataset`).
+- **Feedback**: runtime reward/advantage or none (`runtime_reward` / `none`).
+- **Paradigm**: `coupled`, `decoupled`, or `distillation`.
+- **Dynamics**: coupled objectives require SDE transition densities; decoupled/distillation
+ objectives are solver-agnostic unless their own math narrows the choice.
+- **Supervision**: prompts, demonstrations, preference pairs, or a new typed model-neutral record.
+- **State**: legacy single-component or structured multi-component trajectory.
+- **Ownership**: one live policy, several live trainable roles, or temporal reference/EMA snapshots.
+
+Use a predefined immutable contract when it matches:
+
+| Composition | Constant | Examples |
+|---|---|---|
+| `generation + runtime_reward` | `ONLINE_EXECUTION_CONTRACT` | GRPO, online DPO |
+| `generation + none` | `ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT` | DiffusionOPD, DMD2, TDM |
+| `dataset + none` | `OFFLINE_EXECUTION_CONTRACT` | SFT, offline DPO |
+
+Do not infer these axes from batch fields or expose them as user-configurable fields. A genuinely new
+composition requires an explicit execution-contract/driver design, not a trainer-local branch.
+
+Study the closest direct-`BaseTrainer` implementation: GRPO for coupled replay, NFT/AWM for
+decoupled generation, DiffusionOPD or DMD2/TDM for reward-free generation, and SFT/offline DPO for
+finite data. New trainers default to direct `BaseTrainer` inheritance; the existing sanctioned
+strict extensions are GRPO-Guard/DPPO from GRPO and TDM-R1 from TDM.
+
+## 2. Add Algorithm-Specific Arguments
+
+Create `src/flow_factory/hparams/training_args/my_algo.py`. The arguments class and trainer class
+must declare the same class-level contract; it is not serialized or user-overridable.
```python
-from __future__ import annotations
from dataclasses import dataclass, field
+from typing import ClassVar, Literal
-from ._base import TrainingArguments
+from ...contracts.execution import OFFLINE_EXECUTION_CONTRACT, ExecutionContract
+from ._offline import OfflineFlowMatchingTrainingArguments
@dataclass
-class MyAlgoTrainingArguments(TrainingArguments):
- """Training arguments specific to MyAlgo."""
+class MyAlgoTrainingArguments(OfflineFlowMatchingTrainingArguments):
+ """Configure MyAlgo over finite dataset acquisitions."""
+
+ execution_contract: ClassVar[ExecutionContract] = OFFLINE_EXECUTION_CONTRACT
+ trainer_type: Literal["my-algo"] = field(default="my-algo")
my_specific_param: float = field(
default=0.1,
- metadata={"help": "Description of param."},
- )
- another_param: int = field(
- default=10,
- metadata={"help": "Description of param."},
+ metadata={"help": "Describe the objective parameter."},
)
+
+ def __post_init__(self) -> None:
+ """Validate the fixed trainer identity and objective parameters."""
+ super().__post_init__()
+ if self.trainer_type != "my-algo":
+ raise ValueError("MyAlgoTrainingArguments requires trainer_type='my-algo'")
```
-If the algorithm uses a different CFG `guidance_scale` at optimize time than at sampling/rollout time (e.g., `kl_cfg` for a reference-model branch), override `get_preprocess_guidance_scale()` so the data preprocessing stage encodes negative prompts:
+Dataset algorithms inherit `OfflineFlowMatchingTrainingArguments` so finite-loader cadence,
+explicit accumulation, and offline flow-matching fields remain shared. Generated algorithms
+inherit `TrainingArguments` and use the corresponding online contract constant. If an
+optimize/reference branch uses stronger CFG than rollout, override
+`get_preprocess_guidance_scale()` so negative conditions are encoded. Keep algorithm-owned
+objective validation in this class.
-```python
-def get_preprocess_guidance_scale(self) -> float:
- """Ensure negative prompts are encoded when optimize-time CFG needs them."""
- return max(self.guidance_scale, self.my_optimize_cfg)
-```
+Register and re-export the class in:
-See `topics/adapter_conventions.md` "Classifier-Free Guidance (CFG) Convention" for the full two-stage CFG contract.
+- `hparams/training_args/_registry.py`
+- `hparams/training_args/__init__.py`
+- `hparams/__init__.py`
-### Step 2 — Register in Argument Resolver
+## 3. Implement the Trainer Hook, Not the Loop
-Update three files in `src/flow_factory/hparams/training_args/`:
+`BaseTrainer.start()` owns seeding, acquisition dispatch, periodic save/eval boundaries, progress,
+EMA cadence, and `_after_acquisition_cycle()`. Do not override it or manually advance progress.
+Place the implementation under `src/flow_factory/trainers//my_algo.py` so the relative
+imports below match the existing `rl`, `distillation`, and `offline` package depth.
-**a)** Add import + registry entry in `_registry.py`:
+### Generated acquisition
```python
-from .my_algo import MyAlgoTrainingArguments
+from typing import ClassVar, List, Literal
-_TRAINING_ARGS_REGISTRY: Dict[str, Type[TrainingArguments]] = {
- ...
- 'my_algo': MyAlgoTrainingArguments, # Add this
-}
-```
+from ...contracts import ONLINE_EXECUTION_CONTRACT, ExecutionContract
+from ...samples import BaseSample
+from ..abc import BaseTrainer
-**b)** Add re-export in `__init__.py`:
-```python
-from .my_algo import MyAlgoTrainingArguments
-# Also add to __all__
-```
+class MyOnlineTrainer(BaseTrainer):
+ """Optimize MyAlgo from generated examples and runtime feedback."""
-**c)** Add re-export in `src/flow_factory/hparams/__init__.py`:
+ paradigm: ClassVar[Literal["decoupled"]] = "decoupled"
+ execution_contract: ClassVar[ExecutionContract] = ONLINE_EXECUTION_CONTRACT
-```python
-from .training_args import MyAlgoTrainingArguments
-# Also add to __all__
+ def sample(self) -> List[BaseSample]:
+ """Generate the state required by the objective."""
+ return self.generate_samples(
+ reward_buffer=self.reward_buffer,
+ compute_log_prob=False,
+ trajectory_indices=[-1],
+ )
+
+ def optimize(self, samples: List[BaseSample]) -> None:
+ """Apply the algorithm objective to one generated collection."""
+ ...
```
-## Phase 3: Trainer Implementation
+Prefer `generate_samples()` because it owns rollout mode, source/metadata propagation, reward
+buffering, acceleration context, and loader iteration. Override acquisition internals only when the
+algorithm truly needs a different collection shape and preserve those contracts.
-### Step 3 — Create Trainer Class
+Generated no-feedback trainers select `ONLINE_NO_FEEDBACK_EXECUTION_CONTRACT`, pass no training
+reward buffer, and do not simulate absent feedback with reward no-ops. Override `_run_training_step`
+only for a different grouping of sample/optimize work, while retaining the outer shared loop.
-For a generated online algorithm:
+### Finite dataset acquisition
```python
-# src/flow_factory/trainers/rl/my_online_algo.py
-from ...contracts import ONLINE_EXECUTION_CONTRACT
-from ..abc import BaseTrainer
-from ..registry import register_trainer
-
-@register_trainer("my-online-algo")
-class MyOnlineAlgoTrainer(BaseTrainer):
- """My generated-acquisition algorithm."""
-
- execution_contract = ONLINE_EXECUTION_CONTRACT
-
- # Do NOT define start(). BaseTrainer.start() owns the acquisition loop: reseed,
- # periodic boundaries, acquisition dispatch, EMA, and _after_acquisition_cycle().
- # evaluate(), prepare_feedback(), and compute_advantages() are concrete base methods.
- # A generation trainer implements sample() and optimize(samples).
- #
- # Vary behavior through hooks instead of restating the loop:
- # sampling_context() - wrap the rollout (e.g. install a snapshot's weights)
- # _run_training_step() - replace the sample -> feedback -> optimize middle
- # _after_gradient_step() - run right after each optimizer step
- # _after_acquisition_cycle() - run once per rollout iteration or data epoch
- # _declare_model_variants() - declare several trainable copies (see component_variants.md)
-
- def sample(self):
- """Stages 2-3: K-repeat sampling + trajectory generation."""
- # Use self.adapter.inference() for trajectory generation
- pass
-
- def optimize(self, samples):
- """Stage 6: Policy update."""
- # Use self.adapter.forward() for single-step denoising.
- # Per-forward autocast — never one outer autocast around the loop (#20a).
- # Compute loss, backprop, step
- pass
-```
+from typing import Any, ClassVar, Dict, Literal, Tuple
-For a finite offline algorithm:
+from torch.utils.data import DataLoader
-```python
-# src/flow_factory/trainers/offline/my_offline_algo.py
-from ...contracts import OFFLINE_EXECUTION_CONTRACT
+from ...contracts import OFFLINE_EXECUTION_CONTRACT, ExecutionContract
+from ...data_utils.offline_train_data import build_offline_train_dataloader
from ..abc import BaseTrainer
-from ..registry import register_trainer
-@register_trainer("my-offline-algo")
-class MyOfflineAlgoTrainer(BaseTrainer):
- """My finite-dataset algorithm."""
- paradigm = "decoupled"
- execution_contract = OFFLINE_EXECUTION_CONTRACT
+class MyOfflineTrainer(BaseTrainer):
+ """Optimize MyAlgo over one complete finite loader per data epoch."""
- def _build_train_dataloader(self):
- """Build a finite official-DistributedSampler loader for one typed schema."""
- # Reuse build_offline_train_dataloader when the supervision type matches;
- # otherwise extend the typed schema/collator first.
- ...
+ paradigm: ClassVar[Literal["decoupled"]] = "decoupled"
+ execution_contract: ClassVar[ExecutionContract] = OFFLINE_EXECUTION_CONTRACT
+
+ def _build_train_dataloader(self) -> Tuple[DataLoader, Dict[str, DataLoader]]:
+ """Build the official-distributed-sampler loader for the typed schema."""
+ return build_offline_train_dataloader(...), {}
- def optimize_batch(self, batch):
+ def optimize_batch(self, batch: Any) -> None:
"""Apply one gradient-accumulation microstep from a dataset batch."""
- # Decode output media in the dataset and encode it on demand through
- # adapter.encode_output_state(); never add target VAE latents to the cache.
...
```
-> **Note**: `AdvantageProcessor` is relevant only to `runtime_reward` feedback.
-> Reward-based trainers delegate via `self.advantage_processor.compute_advantages()` — see
-> `architecture.md` "Advantage Computation". `none` feedback bypasses rewards structurally; do not
-> emulate that by overriding reward methods with incidental no-ops.
-
-### Step 4 — Register in Trainer Registry
-
-Add to `_TRAINER_REGISTRY` in `src/flow_factory/trainers/registry.py`:
-
-```python
-'my_algo': 'flow_factory.trainers.rl.my_algo.MyAlgoTrainer',
-```
-
-## Phase 4: Configuration & Examples
-
-Create example config `examples/my_algo/lora/flux1/default.yaml`:
-
-```yaml
-model:
- model_type: "flux1"
- model_name_or_path: "black-forest-labs/FLUX.1-dev"
- finetune_type: "lora"
- target_components: ["transformer"]
-
-train:
- trainer_type: "my_algo"
- my_specific_param: 0.1
- group_size: 4
-
- num_inference_steps: 28
-
-scheduler:
- dynamics_type: "ODE" # Or appropriate dynamics
-
-data:
- datasets:
- - name: default
- dataset_dir: "path/to/dataset" # Folder with train.jsonl / test.jsonl
- train:
- weight: 1
- max_dataset_size: 1024
- eval: {}
-
-rewards:
- - name: "pickscore"
- reward_model: "pickscore"
- weight: 1.0
- batch_size: 16
-
-optimizers:
- - name: default
- learning_rate: 1e-6
- weight_decay: 1e-4
- max_grad_norm: 1.0
-```
-
-## Phase 5: Verification
-
-- [ ] `MyAlgoTrainingArguments` correctly parsed from YAML
-- [ ] `get_training_args_class('my_algo')` returns correct subclass
-- [ ] `get_trainer_class('my_algo')` loads `MyAlgoTrainer`
-- [ ] `execution_contract` matches the argument class and implemented optimization hook
-- [ ] Training runs end-to-end for ≥2 acquisition cycles without errors
-- [ ] Dataset acquisition defines one epoch as one complete finite dataloader traversal
-- [ ] Loss values are numerically reasonable (not NaN, decreasing)
-- [ ] Rewards improve over training when feedback is `runtime_reward`
-- [ ] Offline supervision media is encoded on the fly and excluded from preprocessing caches
-- [ ] Checkpoint save/load works correctly
-- [ ] Works with at least two different model adapters
-- [ ] Coupled algorithms only use SDE dynamics
-- [ ] Decoupled algorithms work with both SDE and ODE dynamics
-
-## Common Pitfalls
-
-1. **Not subclassing `TrainingArguments`** — algorithm-specific params won't be parsed from YAML
-2. **Forgetting `_registry.py` + `__init__.py` updates** — falls back to base `TrainingArguments`, losing custom params
-3. **Using ODE with coupled paradigm** — no log-probabilities available, silent incorrect gradients
-4. **Not calling `self.should_continue_training()`** — infinite loop if `max_epochs` is set
-5. **Duplicating `_initialization()` logic** — already called in `BaseTrainer.__init__`; don't re-prepare modules
-6. **Reimplementing advantage gather/scatter** — use `self.advantage_processor.compute_advantages()` instead; it handles both sampler topologies automatically
-7. **Extending `GRPOTrainer` unnecessarily** — unless your algorithm extends GRPO's PPO-clipped loss, extend `BaseTrainer` directly (as NFT and AWM do)
-8. **Optimizer-time CFG without `get_preprocess_guidance_scale()`** — if your algorithm calls `adapter.forward(guidance_scale=X)` where X > 1.0 but `training_args.guidance_scale` ≤ 1.0, negative prompts won't be encoded at preprocessing time and CFG silently falls back to no-CFG. Override `get_preprocess_guidance_scale()` in your TrainingArguments subclass to return `max(guidance_scale, your_optimize_cfg)`. See DGPO's `kl_cfg` for a real example.
-9. **Inferring online/offline behavior from batch keys** — declare `ExecutionContract`; keep acquisition and feedback independent from the model I/O schema.
-10. **Using `optimize()` for finite data** — dataset acquisition calls `optimize_batch(batch)` and advances `data_epoch` only after clean loader exhaustion.
-11. **Caching target/chosen/rejected latents** — cache prompt/input conditions only; output media is decoded and encoded on demand through the adapter output codec.
+Reuse the demonstration/preference loader when its schema matches; otherwise extend the typed data
+layer first. The driver calls `set_epoch(data_epoch)` and advances only after clean exhaustion.
+Offline optimization must:
+
+- cache input conditions only and encode outputs on demand;
+- call `adapter.prepare_condition_state()` once per batch;
+- use `adapter.encode_output_state()` and adapter-owned offline forward overrides;
+- preserve shared schedule/noise/reference scope for preference arms;
+- use explicit positive gradient accumulation with no partial-window flush.
+
+Evaluation remains generation-based.
+
+## 4. Register the Trainer
+
+Add the canonical lazy path to `_TRAINER_REGISTRY` in `trainers/registry.py`. Registry keys are
+lowercase. A decorator is optional but never replaces the static entry. Verify trainer and argument
+registry keys together and preserve direct Python-path fallback.
+
+## 5. Add Multi-Role Training Only When Required
+
+One live policy uses the default base variant and one optimizer entry. If several trainable copies
+must coexist:
+
+1. Declare ordered role names through `TrainingArguments.required_trainable_roles`; the first owns
+ canonical base routes.
+2. Return a `RoleUpdatePlan` when roles have different cadence.
+3. Let `BaseTrainer._declare_model_variants()` materialize variants before prepare. Algorithm names
+ remain in the trainer layer, not `models/`.
+4. Run forwards under `adapter.use_component_variant(role)` and updates through
+ `RoleOptimizationCoordinator` or existing role-runtime helpers.
+5. Use temporal ref/named/EMA snapshots for frozen or time-shifted weights, never a live variant.
+6. Add one top-level `optimizers:` entry per role. Muon may contribute two parameter groups for one
+ role, so store group tuples rather than assuming one-to-one ownership.
+7. Preserve one prepared `ModelBundle` and one optimizer root. Muon requires a PyTorch build with
+ `torch.optim.Muon` and is supported on DDP/FSDP2, not DeepSpeed/FSDP1. Multi-role DeepSpeed is
+ ZeRO-1/2 only. Multi-role FSDP2 requires `use_orig_params=True`; the framework rebinds the
+ registry after prepare, and optimizer references must point to the prepared root's
+ DTensor-backed parameters.
+8. Declare checkpoint runtime children before exact resume. Resumable saves include all training
+ roles, role counters, optimizer ownership, and variant snapshots.
+
+Use DMD2/TDM/TDM-R1 and `../../knowledge/topics/component_variants.md` as references.
+
+## 6. Configuration and Documentation
+
+Create `examples/{algorithm}/{finetune_type}/{model_type}/default.yaml`:
+
+- runtime rewards only for `runtime_reward`; evaluation rewards are independent;
+- dataset acquisition uses unit source weights, `sampler_type: auto`, `max_epochs`, and explicit
+ accumulation;
+- generation acquisition uses valid grouped-sampler geometry;
+- coupled algorithms use SDE dynamics;
+- `optimizers:` has one named entry per trainable role, including `max_grad_norm` and
+ `optimizer: adamw|muon`;
+- document the objective and selection in `guidance/algorithms.md` and workflow/data changes in the
+ matching guides.
+
+## 7. Verification
+
+- Trainer/argument registries resolve and their contracts match before heavyweight loading.
+- Hook validation selects `optimize` or `optimize_batch` correctly.
+- Run at least two complete acquisition cycles; for dataset mode, test clean exhaustion and a failed
+ batch that must not advance `data_epoch`.
+- Verify numerical objective invariants and legacy/structured state when applicable.
+- Verify model-only save/load and exact-state resume when state semantics change.
+- Test at least two adapters when the objective is model-neutral; dataset algorithms require an
+ offline-capable adapter.
+- Cover DDP and an affected sharded backend. Multi-role changes cover DDP/ZeRO-2/FSDP2; Muon adds
+ positive DDP/FSDP2 and early negative availability/DeepSpeed/FSDP1 cases.
+- Run `/ff-review` before commit.
+
+## Common Failures
+
+- Missing or mismatched argument/trainer `execution_contract` or `paradigm`.
+- Overriding `start()`, manually advancing progress, or duplicating `_initialization()`.
+- Inferring online/offline behavior from a batch or using `optimize()` for finite data.
+- Calling adapter inference directly while losing source, reward, or acceleration invariants.
+- Adding trainer-to-trainer inheritance without matching a sanctioned strict extension and
+ documenting why direct `BaseTrainer` plus shared helpers is insufficient.
+- Reimplementing advantage gather/scatter.
+- Caching target/chosen/rejected state or redrawing candidate-specific input conditions.
+- Using rollout CFG semantics for offline flow matching.
+- Treating a frozen reference as a variant, preparing roles separately, or assuming one group per
+ role.
+- Omitting training roles/runtime children from resumable checkpoints.
diff --git a/.agents/skills/ff-new-model/SKILL.md b/.agents/skills/ff-new-model/SKILL.md
index 18ff636ca..01a7f6e07 100644
--- a/.agents/skills/ff-new-model/SKILL.md
+++ b/.agents/skills/ff-new-model/SKILL.md
@@ -1,120 +1,187 @@
---
name: ff-new-model
-description: "Complete workflow for adding a new model adapter. Covers analysis, sample dataclass, adapter implementation (4 abstract methods + per-modality encoder overrides), registry, example YAML, and verification. Trigger: 'add model', 'support new model', 'integrate model', 'new adapter'."
+description: "Add a Flow-Factory model adapter, including component runtime, model I/O, online trajectory, offline output-state, distributed loading, checkpointing, registry, examples, and parity verification."
---
# New Model Adapter Integration
-> **Authoritative reference**: `guidance/new_model.md` — read it first.
-
-## Prerequisites
-
-Before starting, ensure you understand:
-1. The target model's diffusers pipeline (or that you'll need a pseudo-pipeline)
-2. The task type: Text-to-Image, Image-to-Image, Text-to-Video, Image-to-Video
-3. Which Sample dataclass to extend
-
-## Phase 1: Analysis
-
-1. **Identify the diffusers pipeline** for the target model
- - Check if it exists in `diffusers`: `from diffusers import `
- - If not, you'll need a pseudo-pipeline (see `guidance/new_model.md` advanced section)
-2. **Study an existing adapter** of the same task type:
- - T2I: `models/flux/flux1.py` or `models/stable_diffusion/sd3_5.py`
- - I2I: `models/flux/flux1_kontext.py` or `models/qwen_image/qwen_image_edit_plus.py`
- - T2V: `models/wan/wan2_t2v.py`
- - I2V: `models/wan/wan2_i2v.py`
-3. **Map pipeline components** to adapter responsibilities:
- - Text encoders → `encode_prompt()`, `preprocessing_modules`
- - VAE → `encode_image()` / `decode_latents()`, `preprocessing_modules`
- - Audio encoder/VAE (if any) → `encode_audio()`, `preprocessing_modules`
- - Transformer/UNet → `forward()`, `default_target_modules` (LoRA target layer names), `inference_modules`
-4. **Also read**: `topics/adapter_conventions.md` for upstream alignment rules; `topics/dtype_precision.md` for precision handling in `cast_latents()`.
-
-## Phase 2: Implementation
-
-### Step 1 — Define Sample Dataclass
-
-```python
-# src/flow_factory/models//.py
-@dataclass
-class MyModelSample(T2ISample): # or appropriate base
- _shared_fields: ClassVar[frozenset[str]] = frozenset({})
- # Add model-specific fields if needed
-```
-
-### Step 2 — Create Adapter Class
-
-```python
-class MyModelAdapter(BaseAdapter):
-
- @property
- def preprocessing_modules(self) -> List[str]:
- return ["text_encoder", "vae"] # Components for Stage 1
-
- @property
- def inference_modules(self) -> List[str]:
- return ["vae"] # Components needed at inference time
-
- @property
- def default_target_modules(self) -> List[str]:
- # LoRA target module names used when YAML sets `target_modules: default`.
- # Override only if your transformer uses non-standard attention layer names.
- return ["to_q", "to_k", "to_v", "to_out.0"]
-```
-
-> Which components are trainable is **config-driven**: the YAML `target_components` / `target_modules` fields are resolved by `BaseAdapter._parse_target_modules()` into `self.target_module_map` (set in `__init__`). Adapters do **not** override `target_module_map`.
-
-### Step 3 — Implement Required Methods
-
-| Method | Purpose | Stage | Abstract? |
-|--------|---------|-------|-----------|
-| `load_pipeline()` | Load diffusers pipeline | Init | Yes |
-| `decode_latents()` | Latents → pixels | 3 | Yes |
-| `inference()` | Full multi-step denoising | 3 | Yes |
-| `forward()` | Single-step denoising loss | 6 | Yes |
-| `encode_prompt()` | Text → embeddings | 1 | No (no-op default; override if your model consumes text) |
-| `encode_image()` | Image → latents | 1 | No (no-op default; override if your model consumes images) |
-| `encode_video()` | Video frames → latents | 1 | No (no-op default; override if your model consumes videos) |
-| `encode_audio()` | Audio → embeddings/features | 1 | No (no-op default; override if your model consumes audio) |
-| `preprocess_func()` | Raw inputs → cached tensors (dispatches to the 4 encoders) | 1 | No (concrete, override only for cross-modal preprocessing) |
-
-### Step 4 — Register
-
-Add to `_MODEL_ADAPTER_REGISTRY` in `src/flow_factory/models/registry.py`:
-```python
-'my-model': 'flow_factory.models...MyModelAdapter',
-```
-
-## Phase 3: Configuration
-
-Create example YAML config in `examples/grpo/lora//default.yaml`:
-```yaml
-model:
- model_type: "my-model"
- model_name_or_path: "org/model-name"
- finetune_type: "lora"
- target_components: ["transformer"]
-```
-
-## Phase 4: Verification
-
-Also read: `topics/parity_testing.md` for the 4-layer verification protocol.
-
-- [ ] `load_pipeline()` successfully loads the model
-- [ ] `preprocess_func()` produces correct cached tensors
-- [ ] `inference()` generates valid images/videos
-- [ ] `forward()` computes loss without errors
-- [ ] Training runs end-to-end with GRPO for ≥2 steps
-- [ ] LoRA weights save and reload correctly
-- [ ] Registry entry resolves correctly: `get_model_adapter_class('my-model')`
-- [ ] Example YAML config is valid and complete
-
-## Common Pitfalls
-
-1. **Forgetting to set `preprocessing_modules`** — causes text encoder to stay on GPU, OOM during training
-2. **Wrong `target_components` / `target_modules` (or `default_target_modules`)** — LoRA applied to wrong components/layers, no training effect
-3. **Mismatched `_shared_fields`** — data corruption during batch collation
-4. **Not handling `enable_preprocess=False`** — encoding components not loaded at inference time
-5. **Inconsistent custom field types across samples** — if a custom sample field is `Tensor` on some samples and `List[Tensor]` on others, `gather_samples` will fall back to slow pickle-based `gather_object`. Always canonicalize to a single type in `__post_init__`; prefer `List[Tensor]` for variable-length data.
-6. **Wrong `images`/`condition_images`/`audios` convention** — `preprocess_func()`, `encode_image()`, `encode_video()`, `encode_audio()`, and `inference()` all operate at **batch level**: `images` is `List[List[Image.Image]]` (`MultiImageBatch`), `condition_images` is `List[List[Tensor(C,H,W)]]` (or `List[List[PIL.Image]]` for adapters that declare `python_format_columns`, e.g. Bagel and SenseNova), and `audios` is `List[List[Tensor]]` (`MultiAudioBatch`), where the outer list indexes samples in the batch and the inner list holds each sample's items. Empty samples contribute `[]` (never `None`); single-item samples contribute `[item]` (never a bare element). Never pass a flat `List[Image]` / `List[Tensor]` or unwrap single-element lists — that breaks Arrow's homogeneous-column requirement and forces every downstream consumer to handle three input shapes. For single-condition models, `_standardize_image_input` / `_standardize_video_input` must detect the nested format with `is_multi_image_batch` / `is_multi_video_batch`, extract the first element per sample (`[batch[0] for batch in images]`), and warn if extra conditions are discarded (e.g. `Wan2_I2V._standardize_image_input`, `LTX2_I2AV._standardize_image_input`). See `topics/adapter_conventions.md` Gotcha #5 and #6.
+Read `../../../guidance/new_model.md`, Tier 1,
+`../../knowledge/topics/adapter_conventions.md`, `../../knowledge/topics/component_runtime.md`, and
+`../../knowledge/topics/parity_testing.md`. Read
+`../../knowledge/topics/structured_trajectory.md` for multi-component models and
+`../../../guidance/datasets.md` when adding offline support.
+
+## 1. Design the Adapter Contracts
+
+Decide before implementation:
+
+- task inputs and outputs: image, video, audio-video, first/last frame, or ordered heterogeneous
+ references;
+- eager Diffusers, lazy modular, or explicit pseudo component runtime;
+- canonical logical components, physical ownership roots, optional components, and aliases;
+- legacy single-component or structured multi-component trajectory;
+- online-only or lossless SFT/offline-DPO output-state support;
+- class-level I/O superset and any checkpoint-realized specialization;
+- supported finetune types, batch semantics, dtype policy, and FSDP2 capabilities.
+
+Study a reference by contract rather than name alone: Flux/SD3 for classic image pipelines, Wan for
+conditioned video, LTX2 for structured AV, MiniMax H3 for modular ordered AV, and Bagel/SenseNova for
+pseudo multi-reference runtimes.
+
+All adapters inherit directly from `BaseAdapter`. Model-specific samples inherit the matching
+task-level sample (`T2ISample`, `I2VSample`, `T2AVSample`, `I2AVSample`, etc.), never another
+model-specific sample.
+
+## 2. Build the Component Runtime
+
+All adapters implement `load_pipeline()`. The default `build_component_runtime()` wraps it in
+`ClassicPipelineRuntime`. Lazy modular or explicit-container adapters override
+`build_component_runtime()` and retain `adapter.pipeline` as the compatibility alias.
+
+Declare component behavior precisely:
+
+- canonical lookup owns component identity;
+- overrides hold prepared proxies or checkpoint/LoRA replacements;
+- declared specs are available for explicit lookup and role discovery;
+- lifecycle enumeration contains materialized canonical `torch.nn.Module` values only;
+- optional `None`, lazy-only specs, pseudo aliases, and prepared overrides are not implicit
+ lifecycle roots;
+- `materialize_components(None)` means already materialized modules. Name lazy requirements
+ explicitly.
+
+Use `has_component`, `get_component`, and `_require_component`; never gate component behavior with
+`hasattr(adapter, name)`. Declare `preprocessing_modules` and `inference_modules`. Condition/output
+encoding components are declared by their preparer/codec rather than loaded inside an encode call.
+
+Do not call Accelerator prepare, FSDP wrapping, rank broadcasts, or manual target movement in an
+adapter. `ModelLoadCoordinator` maps logical names to physical roots, and trainer initialization
+prepares one `ModelBundle` plus one optimizer. After prepare, adapter access routes through
+`RoutedComponentProxy`.
+
+## 3. Implement the Core Adapter Surface
+
+`BaseAdapter` keeps four abstract methods:
+
+| Method | Contract |
+|---|---|
+| `load_pipeline()` | Return the native pipeline/container used by the runtime. |
+| `decode_latents()` | Decode generated latent state to media. |
+| `inference()` | Run the full denoising/generation loop. |
+| `forward()` | Run one model step; this is the train-inference parity boundary. |
+
+`encode_prompt`, `encode_image`, `encode_video`, and `encode_audio` are opt-in no-op encoders.
+`preprocess_func()` dispatches them and should be overridden only for cross-modal preprocessing.
+Preserve exact batch nesting: the outer list indexes samples and inner lists hold each sample's
+media items; empty samples contribute `[]`, never `None` or a bare singleton.
+
+Configure training through `default_target_modules`; YAML `target_components`/`target_modules`
+builds `target_module_map`. Do not override the realized map.
+
+`forward()` and `inference()` must agree on all generation-affecting inputs, scheduler state,
+precision, and component order. Use `cast_latents()` symmetrically. Keep algorithm-specific logic
+out of the adapter.
+
+## 4. Add Structured State When Components Differ
+
+For independently shaped or scheduled latent components:
+
+- declare immutable `trajectory_component_order`;
+- build a `SchedulerGroup` with exactly the same names and a canonical primary scheduler;
+- emit `StructuredTrajectory` only, leaving legacy trajectory fields `None`;
+- retain per-component schedules, state/log-prob index maps, callbacks, and active masks;
+- extend protected state/bridge/reduction hooks so trainers consume terminal state, replay steps,
+ forward-process noise, and reductions without inspecting storage format;
+- derive scheduler/RNG/reduction order from the declared tuple, never mapping iteration.
+
+Single-component adapters may retain legacy storage. Do not generalize their reduction order unless
+the change preserves parity.
+
+If a new concrete sample field is required by `__post_init__`, identity normalization, or
+constructor invariants after a partial distributed gather, union it into the inherited
+`reconstruction_required_fields`. That transport contract is separate from collator
+`_shared_fields` and reward-model `required_fields`.
+
+## 5. Declare Offline Output-State Support Explicitly
+
+An adapter that claims SFT/offline-DPO support must provide every boundary below:
+
+1. An immutable `PipelineIOContract` describing model-neutral ordered input/output media,
+ semantic slots/cardinality, rates, geometry owner, and batch capability.
+2. `_resolve_pipeline_io_contract()` only when checkpoint metadata narrows a class-level superset.
+3. A declaration-only `OutputStateCodec` from `build_output_state_codec()`. It declares logical
+ required components but cannot materialize, load, move, replace, or recast them.
+4. `_validate_encoded_output_geometry()` comparing codec output against adapter-owned facts.
+5. A declaration-only `ConditionStatePreparer` only when cached conditions are not the exact
+ forward/output-codec condition.
+6. One `PreparedConditionState` reused across every candidate and policy/reference forward.
+7. A complete immutable `offline_training_forward_overrides` mapping. These values define finite-
+ data model conditioning and are independent of rollout CFG settings.
+8. An offline flow-matching objective reducer only when modality aggregation differs from online
+ trajectory reduction.
+
+Input-condition caches never contain target/chosen/rejected pixels or latent states. Shared numeric
+transforms may serve condition and output paths, but posterior `sample` versus `argmax` policy stays
+explicit at the semantic boundary. Candidate output context cannot overwrite input-owned fields.
+
+If the adapter cannot represent output state losslessly, set a non-empty actionable
+`output_state_codec_unavailable_reason`. Dataset acquisition then fails before model weights load;
+online construction remains valid.
+
+Do not override public boundary-owning wrappers such as `prepare_condition_state`,
+`encode_output_state`, `forward_state`, or the shared reducers. Extend the protected hooks named by
+their errors/docs.
+
+## 6. Preserve Distributed and Checkpoint Ownership
+
+- Declare every target component and frozen-but-shardable sibling needed in the prepared bundle.
+- Preserve `_no_split_modules` or `_repeated_blocks` metadata so FSDP wrap discovery survives the
+ bundle boundary.
+- Opt into `supports_fsdp2_cpu_efficient_loading` only when selective rank-zero/meta target
+ construction is correct. Treat additional wrap classes, default-stream unshard, backward-prefetch
+ opt-out, and in-forward checkpointing as explicit model capabilities.
+- Do not enable activation checkpointing inside `load_pipeline()`. The early backend plan selects
+ one owner: FSDP2 moves a full model policy to backend ownership and rejects selective model
+ policies; adapter-owned in-forward block boundaries require explicit opt-in.
+- Apply component load dtype during native load, then frozen/trainable storage policy. Ensure lazy
+ materialization receives both policies.
+- Save/load trainable components symmetrically. Frozen-but-shardable bundle members do not own
+ checkpoint artifacts. Test through prepared proxies as well as unprepared model-only load.
+
+## 7. Register and Add Examples
+
+Add a lowercase canonical key and lazy class path to `_MODEL_ADAPTER_REGISTRY`; preserve direct
+Python-path fallback. Follow
+`examples/{algorithm}/{finetune_type}/{model_type}/{variant}.yaml` and update path references.
+
+Add a generation example. For declared offline support, add SFT/offline-DPO examples or tested
+fixtures whose strict V2 media matches the effective checkpoint contract. Document supported modes,
+batch limits, geometry, rates, and intentionally unsupported paths.
+
+## 8. Verification
+
+- Runtime construction and lifecycle tests for the chosen classic/modular/pseudo runtime.
+- Native pipeline config, components, one-step forward, final latent, and visual parity.
+- Rollout/training `forward()` parity and initial on-policy ratio for coupled support.
+- Legacy or structured trajectory bridge, active-mask, noise, and reducer tests.
+- Selective-field distributed gather reconstructs every concrete sample with its inherited
+ `reconstruction_required_fields` intact.
+- DDP, ZeRO-2, and FSDP2 initialization/step where supported; verify bundle/proxy routing and FSDP
+ wrap/checkpoint policy.
+- LoRA/full and model-only checkpoint round trips only for finetune types claimed.
+- One complete SFT epoch and offline-DPO epoch for every declared offline I/O mode, including exact
+ geometry and prepared-condition reuse.
+- An online-only adapter's offline selection fails before heavyweight loading.
+- Registry and example parsing tests. Run `/ff-review` before commit.
+
+## Common Failures
+
+- Membership through adapter attributes, eager materialization of all lazy specs, or alias double
+ movement.
+- Preparing components outside the bundle or dropping repeated-block wrap metadata.
+- Scheduler order from a mapping or multimodal state written into legacy fields.
+- Adapter-to-adapter or model-sample-to-model-sample inheritance.
+- Codec/preparer construction with materialization or device/dtype side effects.
+- Using the class I/O superset instead of the checkpoint-effective contract.
+- Redrawing an input condition per preference candidate or leaking sampling CFG into offline loss.
+- Overriding public contract wrappers instead of protected hooks.
+- Enabling both model and backend activation checkpointing.
diff --git a/.agents/skills/ff-new-reward/SKILL.md b/.agents/skills/ff-new-reward/SKILL.md
index 911d7d2dd..564258841 100644
--- a/.agents/skills/ff-new-reward/SKILL.md
+++ b/.agents/skills/ff-new-reward/SKILL.md
@@ -1,118 +1,136 @@
---
name: ff-new-reward
-description: "Complete workflow for adding a new reward model. Covers pointwise vs groupwise design, __call__ contract, registration, YAML config, multi-reward setup, and verification. Trigger: 'add reward', 'new reward model', 'custom reward', 'scoring function'."
+description: "Add a Flow-Factory reward model with pointwise/groupwise dispatch, media conversion, per-dataset routing, async execution, backend-safe loading, registry, configuration, and verification."
---
# New Reward Model Integration
-> **Authoritative reference**: `guidance/rewards.md` — read it first.
-> **Template**: `src/flow_factory/rewards/my_reward.py`
+Read `../../../guidance/rewards.md`, `../../knowledge/constraints.md` #13, and the current
+`rewards/abc.py`, `rewards/reward_processor.py`, and `rewards/loader.py` contracts.
-## Prerequisites
+## 1. Choose the Dispatch Contract
-Determine your reward type:
-- **Pointwise**: Each sample scored independently (e.g., aesthetic score, CLIP similarity)
-- **Groupwise**: Scores depend on comparison within a group (e.g., ranking, preference)
+- **Pointwise**: each input is scored independently. A call receives a non-empty chunk whose length
+ is at most configured `batch_size`; tail chunks and per-dataset applicability gating can make it
+ smaller.
+- **Groupwise**: one call receives a complete `unique_id` group, either local or reconstructed
+ across ranks. Configured pointwise `batch_size` does not define this call.
-## Phase 1: Design
+Return exactly one finite score per input passed to the call, in the same order. Do not return NaN
+as a “not applicable” marker: `RewardProcessor` owns applicability masks, NaN padding outside model
+calls, and `sample.applicable_rewards`.
-1. **Choose base class**: `PointwiseRewardModel` or `GroupwiseRewardModel`
-2. **Identify required inputs**: What fields from `Sample` does your reward need?
- - Common: `prompt`, `image`, `video`, `condition_images`, `condition_videos`
- - Set `required_fields` tuple accordingly
-3. **Input format**: PIL Images (default) or Tensors?
- - Set `use_tensor_inputs = True` if your model needs raw tensors
+Declare only fields needed from `BaseSample` in `required_fields`. Common fields include `prompt`,
+`image`, `video`, `audio`, `condition_images`, and `condition_videos`. Additional JSONL metadata is
+available as one JSON-encoded `metadata` string and must be parsed explicitly.
-## Phase 2: Implementation
+`required_fields` selects reward-consumer data; it does not replace a concrete sample class's
+`reconstruction_required_fields`. If adding a sample field that its constructor or `__post_init__`
+needs after a partial gather, update that inherited class contract separately. Collator
+`_shared_fields` is a third, independent concern.
-### Create the reward model file
+Set `use_tensor_inputs` deliberately:
+
+- `False`: images/video frames arrive as PIL and audio as NumPy;
+- `True`: media arrives as tensors.
+
+Condition media retains nested per-sample item structure.
+
+## 2. Implement the Model
```python
-# src/flow_factory/rewards/.py
-from .abc import PointwiseRewardModel, RewardModelOutput
-from ..hparams import RewardArguments
+from typing import Any, List, Optional
+
+import torch
from accelerate import Accelerator
-from typing import Optional, List
from PIL import Image
-import torch
+
+from ..hparams import RewardArguments
+from .abc import PointwiseRewardModel, RewardModelOutput
+
class MyRewardModel(PointwiseRewardModel):
+ """Score prompt-image alignment for each generated sample."""
+
required_fields = ("prompt", "image")
use_tensor_inputs = False
- def __init__(self, config: RewardArguments, accelerator: Accelerator):
+ def __init__(self, config: RewardArguments, accelerator: Accelerator) -> None:
+ """Load the reward network using the configured device and dtype."""
super().__init__(config, accelerator)
- # Load your model, processor, etc.
- # Use self.device and self.dtype from base class
+ ...
@torch.no_grad()
def __call__(
self,
prompt: List[str],
image: Optional[List[Image.Image]] = None,
- video: Optional[List[List[Image.Image]]] = None,
- audio: Optional[List[torch.Tensor]] = None,
- condition_images=None,
- condition_videos=None,
- **kwargs,
+ **kwargs: Any,
) -> RewardModelOutput:
- # Compute rewards — shape must be (batch_size,) for Pointwise
- # or (group_size,) for Groupwise
- rewards = torch.zeros(len(prompt), device=self.device)
- return RewardModelOutput(rewards=rewards)
+ """Return one finite score per received prompt-media pair."""
+ scores = ...
+ return RewardModelOutput(rewards=scores)
```
-### Key constraints for `__call__`:
-- **Pointwise**: Input length = `config.batch_size`. Return rewards shape `(batch_size,)`
-- **Groupwise**: Input length = `group_size`. You handle batching yourself. Return rewards shape `(group_size,)`
-- Always use `@torch.no_grad()` decorator
-- Return `RewardModelOutput` (not raw tensors)
+Use `self.device` and `self.dtype`; do not hardcode CUDA. Keep public signatures typed and
+Google-style. `@torch.no_grad()` is required for inference-only scoring.
-## Phase 3: Register
+Construction runs inside `ModelLoadCoordinator`'s REWARD load scope and reward resources remain
+full per-rank replicas. A reward implementation must not call `accelerator.prepare()`, enter
+target-only FSDP loading state, or mutate trainer component ownership.
-Add to `_REWARD_MODEL_REGISTRY` in `src/flow_factory/rewards/registry.py`:
-```python
-'my_reward': 'flow_factory.rewards..MyRewardModel',
-```
+## 3. Register and Configure
-## Phase 4: Configuration
+Add a lowercase canonical lazy path to `_REWARD_MODEL_REGISTRY`. Preserve direct Python-path
+fallback; a decorator is optional and does not replace the static entry.
-Use in YAML config:
```yaml
rewards:
- - name: "my_reward"
- reward_model: "my_reward" # Must match registry key
- model_path: "org/model-name" # HuggingFace model path (if applicable)
- dtype: "bfloat16"
- device: "cuda"
+ - name: my_reward
+ reward_model: my_reward
+ model_path: org/model-name
+ dtype: bfloat16
+ device: cuda
batch_size: 16
+ applicable_datasets: [alignment]
+ weight: 1.0
+ async_reward: false
+ num_workers: 1
```
-Multi-reward setup:
-```yaml
-rewards:
- - name: "aesthetic"
- reward_model: "PickScore"
- weight: 0.7
- - name: "custom"
- reward_model: "my_reward"
- weight: 0.3
-```
-
-## Phase 5: Verification
-
-- [ ] `__init__` loads model without errors
-- [ ] `__call__` returns correct reward shape
-- [ ] Rewards are numerically reasonable (not all zeros, no NaN/Inf)
-- [ ] Works with `RewardProcessor` dispatch (Pointwise/Groupwise routing)
-- [ ] Works in multi-reward setup with weight aggregation
-- [ ] Device placement correct (respects `config.device`)
-- [ ] Registry entry resolves: `get_reward_model_class('my_reward')`
-
-## Common Pitfalls
-
-1. **Wrong return shape** — Pointwise must return `(batch_size,)`, Groupwise `(group_size,)`
-2. **Forgetting `@torch.no_grad()`** — causes reward computation to build unnecessary graph, OOM
-3. **Hardcoding device** — use `self.device` from base class, not `torch.device('cuda')`
-4. **Not setting `required_fields`** — `RewardProcessor` won't pass the right data to your model
-5. **Mixing paradigms** — don't inherit `PointwiseRewardModel` if your reward needs group context
+`name` identifies the configured reward stream; `reward_model` resolves its implementation.
+`applicable_datasets` is resolved to dataset names/source IDs, and `weight` may be one scalar or a
+per-dataset mapping. Training and `eval_rewards:` are independent configurations. Runtime training
+rewards are valid only for an execution contract with `feedback=runtime_reward`; reward-free and
+offline trainers may still use evaluation rewards.
+
+The loader deduplicates train/eval entries with the same model identity. Do not keep mutable
+per-config-name state inside a shared model call.
+
+When `async_reward` is enabled, calls run in worker threads and may use dedicated CUDA streams.
+Implementations must be thread-safe or require `num_workers: 1`; tail pointwise batches still run
+during finalize.
+
+## 4. Verification
+
+- Direct pointwise calls at full and tail lengths, or one complete groupwise call.
+- Exact result shape/order and rejection of NaN/Inf.
+- Source-gated partial and no-applicable subsets through `RewardProcessor`.
+- Groupwise local and distributed reconstruction when applicable.
+- Selective groupwise gathers retain every concrete sample `reconstruction_required_fields` entry.
+- PIL/NumPy and tensor media conversion for every declared field.
+- Scalar and per-dataset weighted multi-reward aggregation.
+- Sync and async execution, including async tail flush and thread-safety assumptions.
+- Train/eval deduplication and exactly one REWARD load scope per unique model.
+- Registry lookup, direct-path fallback, device placement, and config parsing.
+- Run `/ff-review` before commit.
+
+## Common Failures
+
+- Assuming every pointwise call has exactly `config.batch_size` inputs.
+- Returning a raw scalar, wrong ordering, non-finite values, or framework-owned applicability NaNs.
+- Omitting a required field or flattening nested condition media.
+- Hardcoding a device or preparing/sharding a reward as a target component.
+- Mutating shared model state by reward configuration name.
+- Using a pointwise base for a group-dependent score, or enabling async workers for non-thread-safe
+ code.
diff --git a/.agents/skills/ff-review/SKILL.md b/.agents/skills/ff-review/SKILL.md
index 83f37bbf7..da6de83dc 100644
--- a/.agents/skills/ff-review/SKILL.md
+++ b/.agents/skills/ff-review/SKILL.md
@@ -1,116 +1,176 @@
---
name: ff-review
-description: "Mandatory pre-commit code review gate. Checks constraint violations, cross-module consistency, and implementation quality. Trigger proactively when changes span multiple files or touch shared infrastructure. Trigger: 'review', 'check before commit'."
+description: "Review Flow-Factory changes before commit or merge for contract violations, cross-module drift, distributed/checkpoint safety, docs consistency, implementation quality, and test evidence."
---
# Code Review Workflow
-## Process Overview
+## 1. Capture the Exact Scope
-```
-1. Capture changes → git diff
-2. Load constraints → .agents/knowledge/constraints.md
-3. Review against constraints and architecture
-4. Route by verdict:
- ✓ Safe → Proceed with commit
- ⚠ Needs-attention → Fix issues, then commit
- ✗ Risky → Halt and report
-```
-
-## Step 1: Capture Changes
+For a commit review, inspect staged and unstaged scopes separately:
```bash
-git diff HEAD # All changes
-git status # Modified files
+git status --short
+git diff --check
+git diff --cached --stat
+git diff --cached
+git diff
```
-## Step 2: Load Context
-
-- Read `.agents/knowledge/constraints.md` — All hard constraints
-- Reference `.agents/knowledge/architecture.md` — Module dependencies
-- Identify which modules are affected by the changes
-
-## Step 3: Review Checklist
-
-### Constraint Compliance
-- [ ] No constraint violations found
-- [ ] Registry entries updated if classes moved/renamed (#1–4)
-- [ ] Pipeline order preserved (#6)
-- [ ] Coupled/decoupled paradigm respected (#7)
-- [ ] Base class interfaces not broken (#11–13)
-- [ ] Config fields synchronized with YAML examples (#15–17)
-
-### Cross-Module Consistency
-- [ ] Changes to `abc.py` base classes reflected in ALL subclasses (grpo, grpo-guard, dppo, nft, awm, dgpo, dpo, crd, diffusion-opd)
-- [ ] Changes to `hparams/` reflected in ALL example configs
-- [ ] Changes to `AdvantageProcessor` compatible with all trainers
-- [ ] Registry keys match actual import paths
-- [ ] Sample dataclass `_shared_fields` consistent
-
-### Implementation Quality
-- [ ] No hardcoded devices (use `self.device` or `accelerator.device`)
-- [ ] `@torch.no_grad()` on reward model `__call__`
-- [ ] Proper synchronization barriers for distributed code
-- [ ] No ZeRO-3 usage
-- [ ] Type annotations on public methods
-
-### Code Style
-- [ ] Black formatting (`line-length=100`)
-- [ ] isort compliance (`profile="black"`)
-- [ ] English comments and docstrings
-- [ ] Apache 2.0 license header on new files
-- [ ] No unnecessary wildcard imports (except `hparams`)
-- [ ] **Top-level imports only** (constraint #22) — see that file for the three sanctioned exceptions (optional deps via `try/except ImportError`, backend-gated runtime feature checks like DeepSpeed/FSDP, unresolvable circular imports).
-
-### Documentation
-- [ ] `guidance/` docs updated if behavior changed
-- [ ] New config fields added to ALL example configs with defaults and `# Options:` comments
-- [ ] PR title follows format: `[{modules}] {type}: {description}`
-
-## Step 4: Route by Verdict
-
-### ✓ Safe
-No issues found. Proceed with commit.
-
-### ⚠ Needs-Attention
-Issues found but fixable:
-1. List each issue with file and line
-2. Fix identified problems
-3. Re-stage and re-review
-
-### ✗ Risky
-Potential breaking changes:
-1. Halt commit
-2. Report findings with severity
-3. Await explicit user approval
-
-## After Commit
-
-- Run `black --check src/ && isort --check src/` to confirm formatting compliance.
-- Verify PR title follows `[{modules}] {type}: {description}` format.
-- If this was a bug fix, follow `topics/fix_patterns.md` archival process.
-
-## Pre-Review Reading
-
-Before reviewing, always read Tier 1: `constraints.md`, `architecture.md`, `philosophy.md`.
-
-Additionally, read based on diff scope:
-
-| Diff touches... | Also read |
-|----------------|-----------|
-| `models/` | `topics/adapter_conventions.md`, `topics/parity_testing.md` |
-| `trainers/` | `topics/train_inference_consistency.md`, `topics/autocast_param_swap.md` |
-| `scheduler/` | `topics/train_inference_consistency.md`, `topics/dtype_precision.md` |
-| New adapter | `topics/adapter_conventions.md`, `topics/parity_testing.md` |
-| dtype/precision | `topics/dtype_precision.md`, `topics/autocast_param_swap.md` |
-
-## Common Issues Found in Review
-
-1. **Registry path stale** — Class moved but registry not updated
-2. **Config field renamed** — YAML examples still use old name
-3. **New config field not in examples** — Users won't discover it; add with default value and `# Options:` comment
-4. **Base class change not propagated** — Subclass override now has wrong signature
-5. **Missing `wait_for_everyone()`** — Distributed deadlock risk
-6. **Reward shape mismatch** — Pointwise returning wrong batch dim
-7. **License header missing** — New files without Apache 2.0 header
-8. **Autocast spans a forward** — flat loss / KL ≈ 0 (fp32 master); see #20a / `topics/autocast_param_swap.md`
+For a PR-wide review, record the base and commit range and inspect that range in addition to local
+changes. Do not assume `git diff HEAD` represents every intended change.
+
+Read Tier 1 and derive affected trainers, adapters, rewards, accelerators, and argument classes from
+their registries; avoid hard-coded component lists.
+
+## 2. Load Scope-Specific References
+
+| Diff touches | Also read |
+|---|---|
+| Execution contracts, trainer loop, offline data | `../../../guidance/workflow.md`, `../../../guidance/algorithms.md`, `../../../guidance/datasets.md` |
+| Adapter semantics or model parity | `../../knowledge/topics/adapter_conventions.md`, `../../knowledge/topics/parity_testing.md` |
+| Component runtime, loading, bundle | `../../knowledge/topics/component_runtime.md` |
+| Trajectory, sample, scheduler group | `../../knowledge/topics/structured_trajectory.md`, `../../knowledge/topics/train_inference_consistency.md` |
+| Variant, role optimizer, Muon | `../../knowledge/topics/component_variants.md` |
+| Dtype, autocast, parameter swaps | `../../knowledge/topics/dtype_precision.md`, `../../knowledge/topics/autocast_param_swap.md` |
+| Gradient checkpoint/FSDP memory | `../../../guidance/new_model.md` checkpointing contract |
+| Reward processing | `../../../guidance/rewards.md` |
+| Acceleration | `../../../guidance/acceleration.md` |
+| `.agents/` | `../../knowledge/docs_maintenance.md`, agent-doc maintenance rule |
+
+## 3. Review Contract Boundaries
+
+### Registries and configuration
+
+- Static registry keys, lazy import paths, direct-path fallback, and argument registry agree.
+- Keys follow canonical naming; moved/renamed classes update every registry/export.
+- Trainer and algorithm-specific arguments declare the same immutable `ExecutionContract`; users
+ cannot configure its axes.
+- Added/renamed/removed fields update all affected examples and consumers. Example paths follow the
+ project convention.
+
+### Execution and data acquisition
+
+- `BaseTrainer.start()` and acquisition drivers remain authoritative. The selected hook is
+ `optimize(samples)` for generation or `optimize_batch(batch)` for dataset acquisition.
+- `generation + runtime_reward`, `generation + none`, and `dataset + none` execute only their
+ declared stages.
+- Dataset training uses an official finite `DistributedSampler`, explicit accumulation, full clean
+ traversal, unit source weights, and no Accelerator preparation of the training loader.
+- Only input conditions enter preprocessing cache; output supervision is encoded on demand.
+- `optimizer_step`, `rollout_iteration`, and `data_epoch` advance at their own boundaries.
+- Exact runtime identity includes changed objective/data/backend/optimizer and replayed evaluation
+ semantics.
+
+### Adapter, pipeline I/O, and trajectories
+
+- Base adapter keeps four abstract methods; adapters and model-specific samples retain flat/task-
+ level inheritance contracts.
+- Offline support declares a valid effective `PipelineIOContract`, declaration-only condition
+ preparer/output codec, exact geometry validation, one prepared condition per request, complete
+ offline forward overrides, and an explicit blocker when unsupported.
+- Public boundary-owning wrappers are not overridden; protected hooks carry specialization.
+- `forward()`/`inference()` preserve inputs, precision, scheduler state, and parity.
+- Structured trajectories own component order, maps, callbacks, masks, noise, and reductions;
+ trainers consume only bridge APIs. Legacy single-component behavior remains unchanged.
+- Concrete sample fields required after partial gather are inherited through
+ `reconstruction_required_fields`; reward `required_fields` and collator `_shared_fields` remain
+ separate contracts.
+
+### Component runtime, loading, and prepared ownership
+
+- Canonical, override, declared, materialized, optional, and alias paths remain distinct.
+- Membership uses runtime APIs, not adapter attributes; omitted lazy materialization does not load
+ all specs.
+- Logical-to-physical loading goes through `ModelLoadCoordinator`; auxiliary/reward roots remain
+ replicas and target-only backend state does not leak.
+- Every target/frozen-shardable/variant route enters one `ModelBundle` prepared with one optimizer;
+ canonical forwards route through `RoutedComponentProxy` afterward.
+- Stable names and `_no_split_modules`/`_repeated_blocks` metadata survive the bundle boundary.
+- Save/load iterates trainable ownership symmetrically and skips frozen-only checkpoint artifacts.
+
+### Variants, optimizers, distributed plans, and checkpoints
+
+- Algorithm vocabulary and role cadence stay in trainers. Temporal ref/EMA/old state is not modeled
+ as a live trainable variant.
+- Variants are declared before prepare; parameter and optimizer ownership is disjoint/exhaustive.
+- No code assumes one group per role. Muon matrices and AdamW fallback groups share one
+ `CompositeOptimizer` root.
+- ZeRO-3 is rejected. Muon availability and DeepSpeed/FSDP1 incompatibility fail before model load;
+ multi-role DeepSpeed is ZeRO-1/2. Multi-role FSDP2 requires `use_orig_params=True`; registry and
+ optimizer references must point to the replacement DTensor-backed parameters owned by the
+ prepared model root.
+- Activation checkpointing has one owner. FSDP2 full policy moves to backend ownership, selective
+ model policy is rejected, and adapter-owned in-forward boundaries require explicit capability.
+- Resumable checkpoints include all training roles/runtime children and validate metadata before
+ Accelerate state mutation. Model-only export scope remains intentional.
+- Distributed checkpoint phases and publication are all-rank symmetric and synchronized.
+
+### Rewards, acceleration, and numerical quality
+
+- Pointwise calls accept tail/source-gated chunks and return one finite value per actual input;
+ groupwise paths preserve complete group order.
+- Per-dataset applicability/weights, async tail flush, and train/eval model deduplication remain
+ correct.
+- Reward-free contracts do not create incidental training reward work.
+- Acceleration entries preserve declared safety/stage and list order; lossy rollout-only plugins do
+ not run on coupled trainers.
+- No hardcoded device bypasses adapter/reward/backend ownership.
+- Each forward has an appropriate autocast boundary when optimizer steps or param swaps occur.
+- Required rank barriers are present without introducing asymmetric collectives or filesystem work.
+
+### Code and documentation quality
+
+- Public functions/methods are typed and have English Google-style docstrings; new source files
+ carry the Apache header.
+- Imports follow project style and sanctioned local-import exceptions only.
+- Errors fail fast with concrete user-facing config values; no silent fallback weakens a contract.
+- README, guidance, examples, code comments/docstrings, and `.agents/` docs describe current owners,
+ supported modes, and paths without claiming unexecuted quality.
+- Published text/log snippets contain no credentials, tokens, personal absolute paths, hostnames, or
+ machine-specific details.
+
+## 4. Verify Proportionally
+
+Run focused tests tied to each changed contract before broad tests. Useful suites include:
+
+- execution: `tests/contracts/test_execution_contract.py`,
+ `tests/trainers/test_execution_kernel.py`;
+- offline: `tests/hparams/test_offline_training_args.py`, offline data/trainer tests;
+- runtime/I/O: component runtime, pipeline contract, output-state lifecycle tests;
+- trajectory: `tests/models/trajectory/` and bridge/reduction tests;
+- variants/optimizer: component variant, role optimization, Muon, and multirole tests;
+- distributed/checkpoint: distributed-plan, checkpoint layout/runtime identity/resume tests;
+- rewards: loader-context and processor/reconstruction tests.
+
+Run Black/isort on changed Python files as the commit gate, then run the documented full-tree checks.
+If the repository has pre-existing full-tree failures, prove the baseline and distinguish them from
+new regressions; do not hide them or expand scope silently. Validate Markdown links and example
+paths for docs changes.
+
+GPU/distributed evidence should cover only affected compositions/backends, but any claimed support
+must have a representative run. Multi-role/Muon or loading/checkpoint changes normally require DDP,
+ZeRO-2, and FSDP2 coverage plus intended early-rejection cases.
+
+## 5. Verdict
+
+- **Safe**: contracts, tests, docs, and evidence agree; proceed only with the user's authorized
+ commit/push scope.
+- **Needs attention**: list each issue with file/line and fix/re-review before commit.
+- **Risky**: halt when behavior, compatibility, data, or distributed correctness remains uncertain
+ and request explicit direction.
+
+After an authorized commit, verify the final diff and formatting. Bug fixes also follow
+`../../knowledge/topics/fix_patterns.md`.
+
+## Frequent Review Findings
+
+- Trainer/argument contract drift or wrong acquisition hook.
+- Offline path invoking rollout/reward or caching supervision state.
+- Adapter capability checked only after weights load.
+- Runtime membership via `hasattr`, alias double movement, or component prepared outside the bundle.
+- Missing frozen-member checkpoint symmetry or lost repeated-block wrap metadata.
+- Frozen reference represented as a variant or one-group-per-role assumption.
+- Training role/runtime child omitted from resume metadata.
+- Muon accepted on an unsupported backend or duplicate activation-checkpoint owners.
+- Public docs naming a pre-refactor function, owner, path, or unverified support status.
From f02b941e9b0097c89cabaa63b5de1aa1b2dadecd Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 08:19:43 +0800
Subject: [PATCH 72/76] fix(examples): move offline fixtures to dataset root
---
.agents/knowledge/topics/fix_patterns.md | 14 ++++
README.md | 4 +
dataset/offline_dpo_sd3_5/train.jsonl | 2 +
.../data => dataset}/sft_sd3_5/train.jsonl | 4 +-
examples/README.md | 4 +-
examples/data/offline_dpo_sd3_5/train.jsonl | 2 -
examples/offline_dpo/lora/sd3_5/default.yaml | 4 +-
examples/sft/lora/sd3_5/default.yaml | 4 +-
guidance/datasets.md | 9 ++-
tests/examples/test_offline_examples.py | 74 +++++++++++++++++++
10 files changed, 108 insertions(+), 13 deletions(-)
create mode 100644 dataset/offline_dpo_sd3_5/train.jsonl
rename {examples/data => dataset}/sft_sd3_5/train.jsonl (51%)
delete mode 100644 examples/data/offline_dpo_sd3_5/train.jsonl
create mode 100644 tests/examples/test_offline_examples.py
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 198877972..a58d3e92e 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -740,6 +740,20 @@ Based on the fix type, write the fix entry to the appropriate document:
must express quality metrics through the evaluation-only reward surface.
- **Related Constraint**: #7
+### Repository dataset fixtures must not live inside the example-config tree
+- **Date**: 2026-08-31
+- **Symptom**: The checked-in SD3.5 SFT and offline-DPO manifests lived under `examples/data`, while
+ every other repository dataset and the public dataset guide used the root `dataset/` hierarchy.
+- **Root Cause**: The initial smoke fixtures were colocated with their configs without preserving
+ the repository boundary between executable example configs and dataset assets.
+- **Fix**: The manifests moved to `dataset/sft_sd3_5` and `dataset/offline_dpo_sd3_5`; their YAML,
+ Markdown links, and directory-depth-sensitive asset paths moved with them. A production-parser
+ regression now loads both configs and manifests and verifies every supervision asset exists.
+- **Lesson**: Treat example configs and their datasets as separate public surfaces. When moving a
+ manifest, recompute every dataset-root-relative media path and test the resolved files rather
+ than checking only the configured directory string.
+- **Related Constraint**: N/A
+
## Cross-refs
- UP: [Hard Constraints](../constraints.md), [Architecture](../architecture.md)
diff --git a/README.md b/README.md
index 4c49a7424..ccdf84ba7 100644
--- a/README.md
+++ b/README.md
@@ -241,6 +241,10 @@ SFT and offline DPO use strict JSONL with `schema_version: 2`. Public media obje
The optional input-only `slot` field binds sparse conditions to adapter-declared semantic
arguments. Unslotted media fills remaining slots positionally; supervision outputs reject slots.
+The checked-in [SFT demonstration fixture](dataset/sft_sd3_5/train.jsonl) and
+[offline-DPO preference fixture](dataset/offline_dpo_sd3_5/train.jsonl) provide minimal examples
+under the repository's canonical `dataset/` root.
+
Prompt and input-condition encodings are cached. Target, chosen, and rejected media are decoded and
encoded on the fly; their VAE latents are never stored in the preprocessing cache. One offline
epoch is one complete dataloader traversal sharded by PyTorch's official `DistributedSampler`. See the
diff --git a/dataset/offline_dpo_sd3_5/train.jsonl b/dataset/offline_dpo_sd3_5/train.jsonl
new file mode 100644
index 000000000..a2185d729
--- /dev/null
+++ b/dataset/offline_dpo_sd3_5/train.jsonl
@@ -0,0 +1,2 @@
+{"schema_version":2,"input":{"prompt":"Render a clean Flow-Factory emblem.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"../../assets/logo-no-bg.png"}]},"rejected":{"media":[{"type":"image","path":"../../assets/wandb_metrics.png"}]}},"metadata":{"fixture":"repository-images"}}
+{"schema_version":2,"input":{"prompt":"Render an experiment dashboard.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"../../assets/wandb_images.png"}]},"rejected":{"media":[{"type":"image","path":"../../assets/logo.png"}]}},"metadata":{"fixture":"repository-images"}}
diff --git a/examples/data/sft_sd3_5/train.jsonl b/dataset/sft_sd3_5/train.jsonl
similarity index 51%
rename from examples/data/sft_sd3_5/train.jsonl
rename to dataset/sft_sd3_5/train.jsonl
index 0e4b77fc1..e3253c3c7 100644
--- a/examples/data/sft_sd3_5/train.jsonl
+++ b/dataset/sft_sd3_5/train.jsonl
@@ -1,2 +1,2 @@
-{"schema_version":2,"input":{"prompt":"Render a clean Flow-Factory emblem.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"../../../assets/logo-no-bg.png"}]}},"metadata":{"fixture":"repository-image"}}
-{"schema_version":2,"input":{"prompt":"Render an experiment dashboard.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"../../../assets/wandb_images.png"}]}},"metadata":{"fixture":"repository-image"}}
+{"schema_version":2,"input":{"prompt":"Render a clean Flow-Factory emblem.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"../../assets/logo-no-bg.png"}]}},"metadata":{"fixture":"repository-image"}}
+{"schema_version":2,"input":{"prompt":"Render an experiment dashboard.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"../../assets/wandb_images.png"}]}},"metadata":{"fixture":"repository-image"}}
diff --git a/examples/README.md b/examples/README.md
index 7d93dbd34..78ff11622 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -27,10 +27,10 @@ ff-train examples/grpo/lora/flux1/default.yaml
## Offline examples
- [`sft` with SD3.5](sft/lora/sd3_5/default.yaml) consumes V2
- `demonstration` records from [`examples/data/sft_sd3_5`](data/sft_sd3_5/train.jsonl).
+ `demonstration` records from [`dataset/sft_sd3_5`](../dataset/sft_sd3_5/train.jsonl).
- [`offline-dpo` with SD3.5](offline_dpo/lora/sd3_5/default.yaml) consumes V2
`preference` records from
- [`examples/data/offline_dpo_sd3_5`](data/offline_dpo_sd3_5/train.jsonl).
+ [`dataset/offline_dpo_sd3_5`](../dataset/offline_dpo_sd3_5/train.jsonl).
The two tiny manifests reuse repository images so their paths resolve without a separate dataset
download. They are configuration and smoke-test fixtures, not quality-training datasets. Offline
diff --git a/examples/data/offline_dpo_sd3_5/train.jsonl b/examples/data/offline_dpo_sd3_5/train.jsonl
deleted file mode 100644
index f8c47b544..000000000
--- a/examples/data/offline_dpo_sd3_5/train.jsonl
+++ /dev/null
@@ -1,2 +0,0 @@
-{"schema_version":2,"input":{"prompt":"Render a clean Flow-Factory emblem.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"../../../assets/logo-no-bg.png"}]},"rejected":{"media":[{"type":"image","path":"../../../assets/wandb_metrics.png"}]}},"metadata":{"fixture":"repository-images"}}
-{"schema_version":2,"input":{"prompt":"Render an experiment dashboard.","media":[]},"supervision":{"type":"preference","chosen":{"media":[{"type":"image","path":"../../../assets/wandb_images.png"}]},"rejected":{"media":[{"type":"image","path":"../../../assets/logo.png"}]}},"metadata":{"fixture":"repository-images"}}
diff --git a/examples/offline_dpo/lora/sd3_5/default.yaml b/examples/offline_dpo/lora/sd3_5/default.yaml
index 1a1698785..de5d1f819 100644
--- a/examples/offline_dpo/lora/sd3_5/default.yaml
+++ b/examples/offline_dpo/lora/sd3_5/default.yaml
@@ -1,4 +1,4 @@
-# Single-process offline-DPO smoke recipe over a V2 preference manifest.
+# Single-process offline-DPO smoke recipe over the checked-in dataset/offline_dpo_sd3_5 fixture.
# Replace the tiny repository-image dataset with real preference pairs for quality runs.
launcher: "accelerate"
config_file: null
@@ -9,7 +9,7 @@ mixed_precision: "bf16"
data:
datasets:
- name: offline_preferences
- dataset_dir: "examples/data/offline_dpo_sd3_5"
+ dataset_dir: "dataset/offline_dpo_sd3_5"
train:
weight: 1 # Offline epochs require unit source weights and full traversal.
max_dataset_size: 2
diff --git a/examples/sft/lora/sd3_5/default.yaml b/examples/sft/lora/sd3_5/default.yaml
index 2d07fa263..3b0648de3 100644
--- a/examples/sft/lora/sd3_5/default.yaml
+++ b/examples/sft/lora/sd3_5/default.yaml
@@ -1,4 +1,4 @@
-# Single-process SFT smoke recipe over a V2 demonstration manifest.
+# Single-process SFT smoke recipe over the checked-in dataset/sft_sd3_5 fixture.
# Replace the tiny repository-image dataset with a real training corpus for quality runs.
launcher: "accelerate"
config_file: null
@@ -9,7 +9,7 @@ mixed_precision: "bf16"
data:
datasets:
- name: offline_demonstrations
- dataset_dir: "examples/data/sft_sd3_5"
+ dataset_dir: "dataset/sft_sd3_5"
train:
weight: 1 # Offline epochs require unit source weights and full traversal.
max_dataset_size: 2
diff --git a/guidance/datasets.md b/guidance/datasets.md
index c4ebe0eea..a2a9acfdd 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -121,9 +121,12 @@ Decoded audio is a detached CPU `float32` waveform shaped `(channels, samples)`.
source-clock truncation, channel conversion, the single model-rate conversion, posterior selection,
and latent packing remain adapter-owned.
-Tiny schema-complete fixtures and configs are available for
-[SFT](../examples/sft/lora/sd3_5/default.yaml) and
-[offline DPO](../examples/offline_dpo/lora/sd3_5/default.yaml).
+Tiny schema-complete fixtures live under the repository `dataset/` root:
+
+- [SFT demonstration manifest](../dataset/sft_sd3_5/train.jsonl) with its
+ [SD3.5 config](../examples/sft/lora/sd3_5/default.yaml).
+- [Offline-DPO preference manifest](../dataset/offline_dpo_sd3_5/train.jsonl) with its
+ [SD3.5 config](../examples/offline_dpo/lora/sd3_5/default.yaml).
### Public offline smoke datasets
diff --git a/tests/examples/test_offline_examples.py b/tests/examples/test_offline_examples.py
new file mode 100644
index 000000000..32a27d93f
--- /dev/null
+++ b/tests/examples/test_offline_examples.py
@@ -0,0 +1,74 @@
+# Copyright 2026 Jayce-Ping
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from pathlib import Path
+
+import pytest
+
+from flow_factory.data_utils.offline_dataset import OfflineSupervisionType, load_offline_manifest
+from flow_factory.data_utils.schema import DemonstrationSupervision
+from flow_factory.hparams import Arguments
+
+ROOT = Path(__file__).resolve().parents[2]
+OFFLINE_EXAMPLES = (
+ (
+ "examples/sft/lora/sd3_5/default.yaml",
+ "dataset/sft_sd3_5",
+ "demonstration",
+ ),
+ (
+ "examples/offline_dpo/lora/sd3_5/default.yaml",
+ "dataset/offline_dpo_sd3_5",
+ "preference",
+ ),
+)
+
+
+@pytest.mark.parametrize(
+ ("config_path", "expected_dataset_dir", "supervision_type"),
+ OFFLINE_EXAMPLES,
+)
+def test_offline_examples_use_repository_dataset_root(
+ config_path: str,
+ expected_dataset_dir: str,
+ supervision_type: OfflineSupervisionType,
+) -> None:
+ """Keep checked-in offline fixtures under the repository dataset root."""
+ config = Arguments.load_from_yaml(str(ROOT / config_path))
+
+ assert len(config.data_args.datasets) == 1
+ configured_dataset_dir = Path(config.data_args.datasets[0].dataset_dir)
+ assert configured_dataset_dir == Path(expected_dataset_dir)
+ assert configured_dataset_dir.parts[0] == "dataset"
+
+ dataset_dir = ROOT / configured_dataset_dir
+ records = load_offline_manifest(
+ dataset_dir / "train.jsonl",
+ supervision_type=supervision_type,
+ )
+ assert len(records) == 2
+
+ assets_dir = (ROOT / "assets").resolve()
+ for record in records:
+ supervision = record.supervision
+ candidates = (
+ (supervision.target,)
+ if isinstance(supervision, DemonstrationSupervision)
+ else (supervision.chosen, supervision.rejected)
+ )
+ for candidate in candidates:
+ for media in candidate.media:
+ media_path = Path(media.path).resolve()
+ assert media_path.is_relative_to(assets_dir)
+ assert media_path.is_file()
From 365380628e2a7cabb0ac81a6796f255b38076ad6 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 08:38:06 +0800
Subject: [PATCH 73/76] fix(data): unify dataset media discriminator as type
---
.agents/knowledge/topics/fix_patterns.md | 17 +++++
README.md | 4 +-
dataset/minimax_h3_ref2va/README.md | 6 +-
dataset/minimax_h3_ref2va/test.jsonl | 4 +-
dataset/minimax_h3_ref2va/train.jsonl | 4 +-
dataset/offline_smoke/build_mini.py | 12 ++--
guidance/datasets.md | 31 +++++----
guidance/new_model.md | 4 +-
src/flow_factory/data_utils/dataset.py | 30 ++++-----
.../data_utils/offline_condition_cache.py | 14 ++---
.../models/minimax_h3/adapters.py | 2 +-
.../models/minimax_h3/workflow.py | 12 ++--
src/flow_factory/samples/references.py | 24 +++----
.../test_offline_condition_cache.py | 14 ++---
tests/data_utils/test_offline_dataset.py | 4 +-
tests/data_utils/test_offline_train_data.py | 5 +-
tests/data_utils/test_ordered_references.py | 63 ++++++++++---------
tests/data_utils/test_schema.py | 4 +-
tests/examples/test_minimax_h3_examples.py | 4 +-
.../models/minimax_h3/test_condition_state.py | 2 +-
tests/models/minimax_h3/test_review_fixes.py | 6 +-
.../minimax_h3/test_workflow_execution.py | 20 +++---
.../test_reward_processor_reconstruction.py | 2 +-
.../samples/test_ordered_reference_samples.py | 24 +++----
tests/trainers/test_collective_packing.py | 2 +-
25 files changed, 172 insertions(+), 142 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index a58d3e92e..8eef41d75 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -754,6 +754,23 @@ Based on the fix type, write the fix entry to the appropriate document:
than checking only the configured directory string.
- **Related Constraint**: N/A
+### Dataset media discriminators must survive projections unchanged
+- **Date**: 2026-08-31
+- **Symptom**: MiniMax H3 Ref2VA examples used a different media discriminator from strict V2
+ records, and offline condition projection translated between the two representations before
+ adapter preprocessing.
+- **Root Cause**: Ordered-reference support introduced a private compatibility representation
+ instead of preserving the public `MediaAsset.type` contract across canonicalization, decoding,
+ cache projection, and adapter dispatch.
+- **Fix**: Online Ref2VA manifests, canonical reference sidecars, decoded entries, offline
+ projection, and MiniMax H3 dispatch now use `type` end to end. The condition-source and H3
+ preprocessing cache versions were advanced so incompatible Arrow caches are rebuilt, and the
+ dataset guide, fixtures, and contract tests follow the same schema.
+- **Lesson**: A projection may change storage shape, such as list-of-struct to canonical JSON, but
+ it should not rename semantic fields. Keep the public discriminator stable until the concrete
+ third-party object-construction boundary and version every cache that stores the old shape.
+- **Related Constraint**: #5
+
## Cross-refs
- UP: [Hard Constraints](../constraints.md), [Architecture](../architecture.md)
diff --git a/README.md b/README.md
index ccdf84ba7..2ce49eac1 100644
--- a/README.md
+++ b/README.md
@@ -229,8 +229,8 @@ The unified structure of dataset is:
## Offline SFT and Preference Data
-SFT and offline DPO use strict JSONL with `schema_version: 2`. Public media objects always use the
-`type` discriminator; `kind` is not accepted in V2:
+SFT and offline DPO use strict JSONL with `schema_version: 2`. Public media objects use `type` as
+their sole discriminator:
```jsonl
{"schema_version":2,"input":{"prompt":"A clean poster.","media":[]},"supervision":{"type":"demonstration","target":{"media":[{"type":"image","path":"targets/poster.png"}]}},"metadata":{}}
diff --git a/dataset/minimax_h3_ref2va/README.md b/dataset/minimax_h3_ref2va/README.md
index f6e8aa3fa..c85df671b 100644
--- a/dataset/minimax_h3_ref2va/README.md
+++ b/dataset/minimax_h3_ref2va/README.md
@@ -9,10 +9,10 @@ identity.
Supported entries:
-- `image`: `kind` and dataset-relative `path`;
-- `video`: `kind`, dataset-relative `path`, optional finite positive `fps`, and optional
+- `image`: `type` and dataset-relative `path`;
+- `video`: `type`, dataset-relative `path`, optional finite positive `fps`, and optional
dataset-relative `audio_path`;
-- `audio`: `kind`, dataset-relative `path`, and optional finite positive `sample_rate`.
+- `audio`: `type`, dataset-relative `path`, and optional finite positive `sample_rate`.
At least one image or video is required; audio-only manifests are invalid. A video `sample_rate`
is valid only when that video also supplies `audio_path`. Manifest `fps` and `sample_rate`
diff --git a/dataset/minimax_h3_ref2va/test.jsonl b/dataset/minimax_h3_ref2va/test.jsonl
index 46a791e4e..eb6d491b8 100644
--- a/dataset/minimax_h3_ref2va/test.jsonl
+++ b/dataset/minimax_h3_ref2va/test.jsonl
@@ -1,2 +1,2 @@
-{"prompt":"Compose a short audiovisual scene while preserving the ordered references.","references":[{"kind":"image","path":"references/style.png"},{"kind":"video","path":"references/motion.mp4","fps":12.0},{"kind":"audio","path":"references/ambience.wav","sample_rate":16000}]}
-{"prompt":"Use the clip motion first, its separate soundtrack, then the style image.","references":[{"kind":"video","path":"references/motion.mp4","fps":12.0,"audio_path":"references/soundtrack.wav","sample_rate":16000},{"kind":"image","path":"references/style.png"}]}
+{"prompt":"Compose a short audiovisual scene while preserving the ordered references.","references":[{"type":"image","path":"references/style.png"},{"type":"video","path":"references/motion.mp4","fps":12.0},{"type":"audio","path":"references/ambience.wav","sample_rate":16000}]}
+{"prompt":"Use the clip motion first, its separate soundtrack, then the style image.","references":[{"type":"video","path":"references/motion.mp4","fps":12.0,"audio_path":"references/soundtrack.wav","sample_rate":16000},{"type":"image","path":"references/style.png"}]}
diff --git a/dataset/minimax_h3_ref2va/train.jsonl b/dataset/minimax_h3_ref2va/train.jsonl
index b3200228c..dcc6c986c 100644
--- a/dataset/minimax_h3_ref2va/train.jsonl
+++ b/dataset/minimax_h3_ref2va/train.jsonl
@@ -1,2 +1,2 @@
-{"prompt":"Create a coherent scene using the references in order.","references":[{"kind":"image","path":"references/style.png"},{"kind":"video","path":"references/motion.mp4","fps":12.0},{"kind":"audio","path":"references/ambience.wav","sample_rate":16000}]}
-{"prompt":"Follow the reference motion and use the separately supplied soundtrack.","references":[{"kind":"video","path":"references/motion.mp4","fps":12.0,"audio_path":"references/soundtrack.wav","sample_rate":16000},{"kind":"image","path":"references/style.png"}]}
+{"prompt":"Create a coherent scene using the references in order.","references":[{"type":"image","path":"references/style.png"},{"type":"video","path":"references/motion.mp4","fps":12.0},{"type":"audio","path":"references/ambience.wav","sample_rate":16000}]}
+{"prompt":"Follow the reference motion and use the separately supplied soundtrack.","references":[{"type":"video","path":"references/motion.mp4","fps":12.0,"audio_path":"references/soundtrack.wav","sample_rate":16000},{"type":"image","path":"references/style.png"}]}
diff --git a/dataset/offline_smoke/build_mini.py b/dataset/offline_smoke/build_mini.py
index 6c5378f39..d9a58e655 100644
--- a/dataset/offline_smoke/build_mini.py
+++ b/dataset/offline_smoke/build_mini.py
@@ -421,14 +421,14 @@ def _candidate_assets(
pool = pools[family]
return {
"chosen": {
- kind: pool[f"chosen_{kind}"]
- for kind in ("video", "audio")
- if f"chosen_{kind}" in pool
+ media_type: pool[f"chosen_{media_type}"]
+ for media_type in ("video", "audio")
+ if f"chosen_{media_type}" in pool
},
"rejected": {
- kind: pool[f"rejected_{kind}"]
- for kind in ("video", "audio")
- if f"rejected_{kind}" in pool
+ media_type: pool[f"rejected_{media_type}"]
+ for media_type in ("video", "audio")
+ if f"rejected_{media_type}" in pool
},
}
pool = pools["image"]
diff --git a/guidance/datasets.md b/guidance/datasets.md
index a2a9acfdd..782fea196 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -84,9 +84,8 @@ remaining slots in declaration order. Duplicate, unknown, and wrong-media-type s
contract validation. Supervision outputs reject `slot` because their order is declared by the
pipeline output contract rather than by condition-argument names.
-Do not write `kind` in a V2 record. Some ordered-reference adapters still consume a validated
-legacy `kind` mapping internally; the V2 condition projection creates that private bridge only at
-the adapter preprocessing boundary. It is not part of the public V2 schema.
+The same `type` discriminator is preserved when V2 media are projected into ordered-reference
+adapter inputs, so public records and adapter preprocessing use one media-entry contract.
### Demonstration supervision
@@ -289,8 +288,8 @@ exact aligned latent duration. For example:
For LTX2 I2AV, bind one image to `first_frame`. H3 FL2VA supports first-only, last-only, and
first-plus-last records; use explicit slots for the last-only form. H3 Ref2VA puts the complete
-ordered image/video/audio reference sequence in `input.media`; the offline projection bridges those
-public `type` objects to the adapter's private legacy reference representation.
+ordered image/video/audio reference sequence in `input.media`; the offline projection preserves
+those ordered `type` entries at the adapter preprocessing boundary.
```jsonl
{"schema_version":2,"input":{"prompt":"Reveal the scene before this ending.","media":[{"type":"image","path":"conditions/end.png","slot":"last_frame"}]},"supervision":{"type":"demonstration","target":{"media":[{"type":"video","path":"targets/story.mp4","fps":24.0},{"type":"audio","path":"targets/story.wav","sample_rate":32000}]}},"metadata":{}}
@@ -457,11 +456,11 @@ directory. See the [FL2VA dataset fixture](../dataset/minimax_h3_fl2va/train.jso
### Ref2VA: `minimax-h3-ref2va`
-The existing online Ref2VA loader uses a legacy non-empty ordered `"references"` array containing
-image, video, and audio entries:
+The online Ref2VA loader uses a non-empty ordered `"references"` array containing image, video,
+and audio entries. Each entry uses the same `type` discriminator as strict V2 media objects:
```jsonl
-{"prompt":"Create a coherent scene using the references in order.","references":[{"kind":"image","path":"references/style.png"},{"kind":"video","path":"references/motion.mp4","fps":12.0},{"kind":"audio","path":"references/ambience.wav","sample_rate":16000}]}
+{"prompt":"Create a coherent scene using the references in order.","references":[{"type":"image","path":"references/style.png"},{"type":"video","path":"references/motion.mp4","fps":12.0},{"type":"audio","path":"references/ambience.wav","sample_rate":16000}]}
```
Array order is semantically significant. It is preserved during validation, encoding, caching, and
@@ -470,19 +469,19 @@ an audio-only array is invalid.
Supported entries:
-| `kind` | Required keys | Optional keys | Decoded value |
+| `type` | Required keys | Optional keys | Decoded value |
|---|---|---|---|
-| `image` | `kind`, `path` | none | RGB image |
-| `video` | `kind`, `path` | `fps`, `audio_path`, `sample_rate` | frames and optional soundtrack |
-| `audio` | `kind`, `path` | `sample_rate` | waveform |
+| `image` | `type`, `path` | none | RGB image |
+| `video` | `type`, `path` | `fps`, `audio_path`, `sample_rate` | frames and optional soundtrack |
+| `audio` | `type`, `path` | `sample_rate` | waveform |
`fps` and `sample_rate` overrides must be finite positive numbers. A video may use its embedded
soundtrack or a separate dataset-relative `audio_path`; a video `sample_rate` override requires
-`audio_path`. Unknown keys and unsupported legacy `kind` values fail before preprocessing.
+`audio_path`. Unknown keys and unsupported `type` values fail before preprocessing.
-This legacy online manifest is distinct from the strict V2 format above. A V2 record always uses
-`input.media[*].type`; offline condition projection performs any required legacy `kind` conversion
-internally.
+The compact online manifest and strict V2 format share the same media-entry discriminator. Offline
+condition projection preserves `input.media[*].type` when it constructs the ordered-reference
+manifest consumed by the adapter.
See the [Ref2VA dataset fixture](../dataset/minimax_h3_ref2va/train.jsonl), its
[local fixture notes](../dataset/minimax_h3_ref2va/README.md), and the
diff --git a/guidance/new_model.md b/guidance/new_model.md
index b3b948edc..4b8f8c0fa 100644
--- a/guidance/new_model.md
+++ b/guidance/new_model.md
@@ -633,8 +633,8 @@ and distillation continue to rely on their established trajectory-wide reduction
SenseNova is an example of an important boundary: its existing condition schema uses grouped
`images` with within-type order. Do not advertise heterogeneous ordered references merely because
-several images are accepted. The public V2 discriminator remains `type`; conversion to a legacy
-adapter-internal `kind` entry, when genuinely required, belongs only in the condition projection.
+several images are accepted. Dataset media and ordered-reference entries use `type` as their sole
+discriminator, including at the adapter preprocessing boundary.
## Advanced: Custom `preprocess_func`
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index d24a1dd5a..69330ba96 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -1245,7 +1245,7 @@ def _supports_ordered_references(preprocess_func: Optional[Callable]) -> bool:
def _canonicalize_ordered_reference_value(value: Any, row_index: int) -> str:
- """Canonicalize either a legacy reference list or an opaque Arrow string."""
+ """Canonicalize either an ordered reference list or an opaque Arrow string."""
if isinstance(value, str):
references = parse_reference_manifest(value, row_index=row_index)
else:
@@ -1319,14 +1319,14 @@ def _load_ordered_reference(
reference_index: int,
) -> Dict[str, Any]:
"""Decode one ordered reference with dataset row/reference context."""
- kind = entry["kind"]
+ reference_type = entry["type"]
resolved_path = _resolve_path(data_root, entry["path"])
failing_path = resolved_path
loaded = dict(entry)
try:
- if kind == "image":
+ if reference_type == "image":
loaded["media"] = Image.open(resolved_path).convert("RGB")
- elif kind == "video":
+ elif reference_type == "video":
frames, fps, audio, sample_rate = _decode_ordered_video(resolved_path)
effective_fps = entry.get("fps", fps)
_require_finite_positive_rate(
@@ -1334,7 +1334,7 @@ def _load_ordered_reference(
"effective fps",
row_index,
reference_index,
- kind,
+ reference_type,
resolved_path,
)
loaded["frames"] = frames
@@ -1349,7 +1349,7 @@ def _load_ordered_reference(
"sample_rate",
row_index,
reference_index,
- kind,
+ reference_type,
failing_path,
)
effective_sample_rate = entry.get("sample_rate", sample_rate)
@@ -1358,19 +1358,19 @@ def _load_ordered_reference(
"effective sample_rate",
row_index,
reference_index,
- kind,
+ reference_type,
failing_path,
)
loaded["audio"] = audio
loaded["sample_rate"] = effective_sample_rate
- elif kind == "audio":
+ elif reference_type == "audio":
audio, sample_rate = _decode_ordered_audio(resolved_path)
_require_finite_positive_rate(
sample_rate,
"sample_rate",
row_index,
reference_index,
- kind,
+ reference_type,
resolved_path,
)
effective_sample_rate = entry.get("sample_rate", sample_rate)
@@ -1379,19 +1379,21 @@ def _load_ordered_reference(
"effective sample_rate",
row_index,
reference_index,
- kind,
+ reference_type,
resolved_path,
)
loaded["media"] = audio
loaded["sample_rate"] = effective_sample_rate
else:
raise ValueError(
- "expected ordered reference kind in ('image', 'video', 'audio'), " f"got {kind!r}"
+ "expected ordered reference type in ('image', 'video', 'audio'), "
+ f"got {reference_type!r}"
)
except (FileNotFoundError, ImportError, OSError, RuntimeError, ValueError) as error:
raise ValueError(
f"failed to decode ordered reference at row {row_index}, "
- f"reference {reference_index}, kind={kind!r}, path={failing_path!r}: {error}"
+ f"reference {reference_index}, type={reference_type!r}, "
+ f"path={failing_path!r}: {error}"
) from error
return loaded
@@ -1401,7 +1403,7 @@ def _require_finite_positive_rate(
rate_name: str,
row_index: int,
reference_index: int,
- kind: str,
+ reference_type: str,
media_path: str,
) -> None:
if (
@@ -1411,7 +1413,7 @@ def _require_finite_positive_rate(
or value <= 0
):
raise ValueError(
- f"at row {row_index}, reference {reference_index}, kind={kind!r}, "
+ f"at row {row_index}, reference {reference_index}, type={reference_type!r}, "
f"path={media_path!r}, expected decoded {rate_name} to be finite positive, "
f"got {value!r}"
)
diff --git a/src/flow_factory/data_utils/offline_condition_cache.py b/src/flow_factory/data_utils/offline_condition_cache.py
index 94b85d4e6..9980435d6 100644
--- a/src/flow_factory/data_utils/offline_condition_cache.py
+++ b/src/flow_factory/data_utils/offline_condition_cache.py
@@ -50,7 +50,7 @@
)
from .schema import MediaAsset, NormalizedDatasetRecord
-_CONDITION_SOURCE_FORMAT = "flow-factory-offline-condition-v3"
+_CONDITION_SOURCE_FORMAT = "flow-factory-offline-condition-v4"
def project_offline_condition_dataset(
@@ -65,9 +65,9 @@ def project_offline_condition_dataset(
Grouped adapters receive ``prompt`` plus per-modality ``images``, ``videos``,
and ``audios`` columns. Ordered-reference adapters receive one canonical JSON
- string per row instead of an Arrow list-of-struct column. The string is
- restored to validated legacy ``kind`` entries only at the adapter preprocess
- boundary, avoiding Arrow's heterogeneous-struct null-key expansion.
+ string per row instead of an Arrow list-of-struct column. The string preserves
+ validated ``type`` entries through the adapter preprocess boundary, avoiding
+ Arrow's heterogeneous-struct null-key expansion.
"""
if not isinstance(ordered_references, bool):
raise TypeError(
@@ -148,7 +148,7 @@ def project_offline_condition_dataset(
if ordered_references:
columns["references"] = [
canonicalize_reference_manifest(
- [_to_legacy_reference(asset) for asset in record.model_input.media],
+ [_to_ordered_reference(asset) for asset in record.model_input.media],
row_index=index,
)
for index, record in enumerate(stable_records)
@@ -339,8 +339,8 @@ def build_offline_condition_cache(
return condition_cache
-def _to_legacy_reference(asset: MediaAsset) -> Dict[str, Any]:
- reference: Dict[str, Any] = {"kind": asset.type, "path": asset.path}
+def _to_ordered_reference(asset: MediaAsset) -> Dict[str, Any]:
+ reference: Dict[str, Any] = {"type": asset.type, "path": asset.path}
if asset.type == "video" and asset.fps is not None:
reference["fps"] = asset.fps
elif asset.type == "audio" and asset.sample_rate is not None:
diff --git a/src/flow_factory/models/minimax_h3/adapters.py b/src/flow_factory/models/minimax_h3/adapters.py
index 719d1c927..dca81020b 100644
--- a/src/flow_factory/models/minimax_h3/adapters.py
+++ b/src/flow_factory/models/minimax_h3/adapters.py
@@ -72,7 +72,7 @@
)
_H3_PREPROCESS_CACHE_FIELDS = frozenset({"height", "width", "num_frames"})
-_H3_PREPROCESS_CACHE_VERSION = "minimax-h3-v2"
+_H3_PREPROCESS_CACHE_VERSION = "minimax-h3-v3"
logger = setup_logger(__name__)
_H3_OPTIONAL_AUDIO_REFERENCE_FORMAT = MediaFormat(
type=MediaType.AUDIO,
diff --git a/src/flow_factory/models/minimax_h3/workflow.py b/src/flow_factory/models/minimax_h3/workflow.py
index b4fc9dd50..25bee6b56 100644
--- a/src/flow_factory/models/minimax_h3/workflow.py
+++ b/src/flow_factory/models/minimax_h3/workflow.py
@@ -653,20 +653,22 @@ def _build_pinned_references(entries: Sequence[Mapping[str, Any]]) -> List[Any]:
symbols = require_minimax_h3_support()
references = []
for entry in entries:
- kind = entry["kind"]
- if kind == "image":
+ reference_type = entry["type"]
+ if reference_type == "image":
references.append(symbols.ImageReference(image=entry["media"]))
- elif kind == "video":
+ elif reference_type == "video":
reference_kwargs = {"frames": entry["frames"], "fps": entry["fps"]}
if entry.get("audio") is not None:
reference_kwargs.update(audio=entry["audio"], sample_rate=entry["sample_rate"])
references.append(symbols.VideoReference(**reference_kwargs))
- elif kind == "audio":
+ elif reference_type == "audio":
references.append(
symbols.AudioReference(audio=entry["media"], sample_rate=entry["sample_rate"])
)
else:
- raise ValueError(f"expected image/video/audio reference kind, received {kind!r}")
+ raise ValueError(
+ f"expected image/video/audio reference type, received {reference_type!r}"
+ )
return references
diff --git a/src/flow_factory/samples/references.py b/src/flow_factory/samples/references.py
index c62612864..cb5dbf9ba 100644
--- a/src/flow_factory/samples/references.py
+++ b/src/flow_factory/samples/references.py
@@ -19,9 +19,9 @@
from typing import Any, Dict, List
_REFERENCE_KEYS = {
- "image": frozenset({"kind", "path"}),
- "video": frozenset({"kind", "path", "fps", "audio_path", "sample_rate"}),
- "audio": frozenset({"kind", "path", "sample_rate"}),
+ "image": frozenset({"type", "path"}),
+ "video": frozenset({"type", "path", "fps", "audio_path", "sample_rate"}),
+ "audio": frozenset({"type", "path", "sample_rate"}),
}
@@ -39,10 +39,10 @@ def canonicalize_reference_manifest(references: Any, row_index: int) -> str:
_validate_reference_entry(entry, row_index, reference_index)
for reference_index, entry in enumerate(references)
]
- if not any(entry["kind"] in ("image", "video") for entry in validated):
+ if not any(entry["type"] in ("image", "video") for entry in validated):
raise ValueError(
f"at row {row_index}, reference 0, expected at least one image or video "
- f"reference, got audio-only kinds={[entry['kind'] for entry in validated]!r}"
+ f"reference, got audio-only types={[entry['type'] for entry in validated]!r}"
)
return json.dumps(validated, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
@@ -69,11 +69,11 @@ def _validate_reference_entry(
f"expected object at row {row_index}, reference {reference_index}, "
f"got {type(entry).__name__}: {entry!r}"
)
- kind = entry.get("kind")
- if not isinstance(kind, str) or kind not in _REFERENCE_KEYS:
+ reference_type = entry.get("type")
+ if not isinstance(reference_type, str) or reference_type not in _REFERENCE_KEYS:
raise ValueError(
- f"at row {row_index}, reference {reference_index}, expected kind in "
- f"{tuple(_REFERENCE_KEYS)}, got {kind!r}"
+ f"at row {row_index}, reference {reference_index}, expected type in "
+ f"{tuple(_REFERENCE_KEYS)}, got {reference_type!r}"
)
path = entry.get("path")
if not isinstance(path, str) or not path:
@@ -82,11 +82,11 @@ def _validate_reference_entry(
f"a non-empty string, got {path!r}"
)
- unknown_keys = set(entry) - _REFERENCE_KEYS[kind]
+ unknown_keys = set(entry) - _REFERENCE_KEYS[reference_type]
if unknown_keys:
raise ValueError(
f"at row {row_index}, reference {reference_index}, unknown keys "
- f"{sorted(unknown_keys)} for kind {kind!r}"
+ f"{sorted(unknown_keys)} for type {reference_type!r}"
)
for rate_name in ("fps", "sample_rate"):
if rate_name in entry:
@@ -101,7 +101,7 @@ def _validate_reference_entry(
f"at row {row_index}, reference {reference_index}, expected "
f"{rate_name} to be finite positive numeric, got {rate!r}"
)
- if kind == "video":
+ if reference_type == "video":
audio_path = entry.get("audio_path")
if audio_path is not None and (not isinstance(audio_path, str) or not audio_path):
raise ValueError(
diff --git a/tests/data_utils/test_offline_condition_cache.py b/tests/data_utils/test_offline_condition_cache.py
index 9f227046c..260e18594 100644
--- a/tests/data_utils/test_offline_condition_cache.py
+++ b/tests/data_utils/test_offline_condition_cache.py
@@ -851,11 +851,11 @@ def test_ordered_heterogeneous_references_cross_arrow_as_canonical_json(
assert isinstance(raw_manifest, str)
assert [set(reference) for reference in raw_references] == [
- {"kind", "path"},
- {"kind", "path", "fps"},
- {"kind", "path", "sample_rate"},
+ {"type", "path"},
+ {"type", "path", "fps"},
+ {"type", "path", "sample_rate"},
]
- assert [reference["kind"] for reference in raw_references] == [
+ assert [reference["type"] for reference in raw_references] == [
"image",
"video",
"audio",
@@ -872,9 +872,9 @@ def test_ordered_heterogeneous_references_cross_arrow_as_canonical_json(
)
loaded = preprocessor.references[0]
- assert set(loaded[0]) == {"kind", "path", "media"}
- assert set(loaded[1]) == {"kind", "path", "fps", "frames"}
- assert set(loaded[2]) == {"kind", "path", "sample_rate", "media"}
+ assert set(loaded[0]) == {"type", "path", "media"}
+ assert set(loaded[1]) == {"type", "path", "fps", "frames"}
+ assert set(loaded[2]) == {"type", "path", "sample_rate", "media"}
assert cache[0][OFFLINE_CONDITION_ID_COLUMN] == compute_offline_condition_id(
record,
index=0,
diff --git a/tests/data_utils/test_offline_dataset.py b/tests/data_utils/test_offline_dataset.py
index 23ce8b16f..849245e4f 100644
--- a/tests/data_utils/test_offline_dataset.py
+++ b/tests/data_utils/test_offline_dataset.py
@@ -232,8 +232,8 @@ def test_manifest_reader_preserves_order_resolves_paths_and_requires_one_type(
{
"schema_version": 2,
"input": {
- "prompt": "legacy key",
- "media": [{"kind": "image", "path": "input.png"}],
+ "prompt": "unknown media key",
+ "media": [{"type": "image", "path": "input.png", "media_type": "image"}],
},
"supervision": {
"type": "demonstration",
diff --git a/tests/data_utils/test_offline_train_data.py b/tests/data_utils/test_offline_train_data.py
index de53c7310..8e0f5c048 100644
--- a/tests/data_utils/test_offline_train_data.py
+++ b/tests/data_utils/test_offline_train_data.py
@@ -488,7 +488,7 @@ def test_builder_supports_homogeneous_offline_preference_sources(tmp_path: Path)
assert "rejected.png" not in repr(dataset._condition_cache[0])
-def test_builder_uses_bridge_ordered_reference_boundary_with_single_row_batches(
+def test_builder_preserves_ordered_reference_type_with_single_row_batches(
tmp_path: Path,
) -> None:
dataset_dir = tmp_path / "ordered"
@@ -510,8 +510,7 @@ def test_builder_uses_bridge_ordered_reference_boundary_with_single_row_batches(
)
assert preprocessor.references is not None
- assert preprocessor.references[0][0]["kind"] == "image"
- assert "type" not in preprocessor.references[0][0]
+ assert preprocessor.references[0][0]["type"] == "image"
(dataset,) = _source_datasets(loader)
assert dataset[0].model_input.media[0].type == "image"
diff --git a/tests/data_utils/test_ordered_references.py b/tests/data_utils/test_ordered_references.py
index 6cc984eac..33f64f21a 100644
--- a/tests/data_utils/test_ordered_references.py
+++ b/tests/data_utils/test_ordered_references.py
@@ -27,9 +27,9 @@
from flow_factory.models.minimax_h3.adapters import MiniMaxH3Ref2VAAdapter
REFERENCES = [
- {"kind": "image", "path": "subject.png"},
- {"kind": "video", "path": "motion.mp4", "fps": 29.97},
- {"kind": "audio", "path": "voice.wav", "sample_rate": 44100},
+ {"type": "image", "path": "subject.png"},
+ {"type": "video", "path": "motion.mp4", "fps": 29.97},
+ {"type": "audio", "path": "voice.wav", "sample_rate": 44100},
]
@@ -125,8 +125,8 @@ def _write_video(path: Path, with_audio: bool) -> None:
def test_ordered_references_round_trip_real_media_and_merged_cache(tmp_path: Path) -> None:
dataset_dir = tmp_path / "dataset"
references = [
- {"kind": "image", "path": "subject.png"},
- {"kind": "audio", "path": "voice.wav", "sample_rate": 22050},
+ {"type": "image", "path": "subject.png"},
+ {"type": "audio", "path": "voice.wav", "sample_rate": 22050},
]
_write_jsonl(dataset_dir, references)
Image.new("RGB", (4, 3), color=(12, 34, 56)).save(dataset_dir / "subject.png")
@@ -142,7 +142,7 @@ def test_ordered_references_round_trip_real_media_and_merged_cache(tmp_path: Pat
)
assert len(preprocessor.received) == 1
- assert [entry["kind"] for entry in preprocessor.received[0]] == ["image", "audio"]
+ assert [entry["type"] for entry in preprocessor.received[0]] == ["image", "audio"]
assert preprocessor.received[0][0]["media"].size == (4, 3)
assert preprocessor.received[0][1]["sample_rate"] == 22050
assert preprocessor.received[0][1]["media"].shape[0] == 1
@@ -162,7 +162,7 @@ def test_ordered_references_round_trip_real_media_and_merged_cache(tmp_path: Pat
def test_video_reference_preserves_frames_fps_embedded_audio_and_rate(tmp_path: Path) -> None:
dataset_dir = tmp_path / "dataset"
- references = [{"kind": "video", "path": "motion.mp4"}]
+ references = [{"type": "video", "path": "motion.mp4"}]
_write_jsonl(dataset_dir, references)
video_path = dataset_dir / "motion.mp4"
_write_video(video_path, with_audio=True)
@@ -188,8 +188,8 @@ def test_reference_decode_error_has_row_reference_and_cause(tmp_path: Path) -> N
dataset_dir = tmp_path / "dataset"
dataset_dir.mkdir()
rows = [
- {"prompt": "valid", "references": [{"kind": "image", "path": "valid.png"}]},
- {"prompt": "missing", "references": [{"kind": "image", "path": "missing.png"}]},
+ {"prompt": "valid", "references": [{"type": "image", "path": "valid.png"}]},
+ {"prompt": "missing", "references": [{"type": "image", "path": "missing.png"}]},
]
(dataset_dir / "train.jsonl").write_text(
"".join(json.dumps(row) + "\n" for row in rows),
@@ -218,7 +218,7 @@ def test_soundtrack_decode_error_reports_soundtrack_path_and_context(tmp_path: P
dataset_dir = tmp_path / "dataset"
references = [
{
- "kind": "video",
+ "type": "video",
"path": "motion.mp4",
"audio_path": "missing-soundtrack.wav",
}
@@ -229,7 +229,7 @@ def test_soundtrack_decode_error_reports_soundtrack_path_and_context(tmp_path: P
with pytest.raises(
ValueError,
- match=r"row 0.*reference 0.*kind='video'.*missing-soundtrack\.wav",
+ match=r"row 0.*reference 0.*type='video'.*missing-soundtrack\.wav",
) as caught:
GeneralDataset(
dataset_dir=str(dataset_dir),
@@ -244,7 +244,7 @@ def test_soundtrack_decode_error_reports_soundtrack_path_and_context(tmp_path: P
@pytest.mark.parametrize(
- ("kind", "decode_target", "decode_result", "rate_name"),
+ ("media_type", "decode_target", "decode_result", "rate_name"),
[
(
"video",
@@ -269,14 +269,17 @@ def test_soundtrack_decode_error_reports_soundtrack_path_and_context(tmp_path: P
def test_decoded_rates_must_be_finite_positive_with_context(
tmp_path: Path,
monkeypatch,
- kind: str,
+ media_type: str,
decode_target: str,
decode_result: Any,
rate_name: str,
) -> None:
- dataset_dir = tmp_path / f"dataset-{kind}-{rate_name}"
- path = "media.mp4" if kind == "video" else "media.wav"
- references = [{"kind": "image", "path": "valid.png"}, {"kind": kind, "path": path}]
+ dataset_dir = tmp_path / f"dataset-{media_type}-{rate_name}"
+ path = "media.mp4" if media_type == "video" else "media.wav"
+ references = [
+ {"type": "image", "path": "valid.png"},
+ {"type": media_type, "path": path},
+ ]
_write_jsonl(dataset_dir, references)
Image.new("RGB", (2, 2)).save(dataset_dir / "valid.png")
monkeypatch.setattr(decode_target, lambda media_path: decode_result)
@@ -284,7 +287,7 @@ def test_decoded_rates_must_be_finite_positive_with_context(
with pytest.raises(
ValueError,
- match=rf"row 0.*reference 1.*kind='{kind}'.*{rate_name}.*finite positive",
+ match=rf"row 0.*reference 1.*type='{media_type}'.*{rate_name}.*finite positive",
):
GeneralDataset(
dataset_dir=str(dataset_dir),
@@ -304,7 +307,7 @@ def test_video_uses_manifest_fps_when_decoder_has_no_rate(tmp_path: Path, monkey
)
loaded = _load_ordered_reference(
- {"kind": "video", "path": "media.mp4", "fps": 24.0},
+ {"type": "video", "path": "media.mp4", "fps": 24.0},
data_root=str(tmp_path),
row_index=3,
reference_index=4,
@@ -323,10 +326,10 @@ def test_video_without_override_rejects_missing_decoded_fps(tmp_path: Path, monk
with pytest.raises(
ValueError,
- match=r"row 3.*reference 4.*kind='video'.*effective fps.*finite positive.*None",
+ match=r"row 3.*reference 4.*type='video'.*effective fps.*finite positive.*None",
):
_load_ordered_reference(
- {"kind": "video", "path": "media.mp4"},
+ {"type": "video", "path": "media.mp4"},
data_root=str(tmp_path),
row_index=3,
reference_index=4,
@@ -335,7 +338,7 @@ def test_video_without_override_rejects_missing_decoded_fps(tmp_path: Path, monk
@pytest.mark.parametrize("rate", [float("nan"), float("inf"), float("-inf"), 0.0, -1.0])
@pytest.mark.parametrize(
- ("kind", "rate_name", "decode_target", "decode_result"),
+ ("media_type", "rate_name", "decode_target", "decode_result"),
[
(
"video",
@@ -355,17 +358,17 @@ def test_effective_override_rates_must_be_finite_positive(
tmp_path: Path,
monkeypatch,
rate: float,
- kind: str,
+ media_type: str,
rate_name: str,
decode_target: str,
decode_result: Any,
) -> None:
monkeypatch.setattr(decode_target, lambda media_path: decode_result)
- entry = {"kind": kind, "path": f"media.{kind}", rate_name: rate}
+ entry = {"type": media_type, "path": f"media.{media_type}", rate_name: rate}
with pytest.raises(
ValueError,
- match=rf"row 3.*reference 4.*kind='{kind}'.*effective {rate_name}.*finite positive",
+ match=rf"row 3.*reference 4.*type='{media_type}'.*effective {rate_name}.*finite positive",
):
_load_ordered_reference(
entry,
@@ -474,14 +477,14 @@ def fingerprint(preprocess_kwargs: Dict[str, Any]) -> str:
"changed_references",
[
[
- {"kind": "image", "path": "subject.png"},
- {"kind": "video", "path": "motion.mp4", "fps": 24.0},
- {"kind": "audio", "path": "voice.wav", "sample_rate": 44100},
+ {"type": "image", "path": "subject.png"},
+ {"type": "video", "path": "motion.mp4", "fps": 24.0},
+ {"type": "audio", "path": "voice.wav", "sample_rate": 44100},
],
[
- {"kind": "image", "path": "subject.png"},
- {"kind": "video", "path": "motion.mp4", "fps": 29.97},
- {"kind": "audio", "path": "voice.wav", "sample_rate": 48000},
+ {"type": "image", "path": "subject.png"},
+ {"type": "video", "path": "motion.mp4", "fps": 29.97},
+ {"type": "audio", "path": "voice.wav", "sample_rate": 48000},
],
list(reversed(REFERENCES)),
],
diff --git a/tests/data_utils/test_schema.py b/tests/data_utils/test_schema.py
index 9c09f5a30..128b936ad 100644
--- a/tests/data_utils/test_schema.py
+++ b/tests/data_utils/test_schema.py
@@ -195,11 +195,11 @@ def test_normalization_expands_dataset_root_and_normalizes_absolute_paths(
@pytest.mark.parametrize(
"media",
[
- {"kind": "image", "path": "image.png"},
+ {"type": "image", "path": "image.png", "media_type": "image"},
{"type": "image", "path": "image.png", "unknown": True},
],
)
-def test_v2_media_rejects_legacy_kind_and_unknown_keys(media: Dict[str, Any]) -> None:
+def test_v2_media_rejects_unknown_keys(media: Dict[str, Any]) -> None:
raw = _demonstration_record(input={"prompt": "strict", "media": [media]})
with pytest.raises(ValidationError):
diff --git a/tests/examples/test_minimax_h3_examples.py b/tests/examples/test_minimax_h3_examples.py
index e56f19d56..c77014b8c 100644
--- a/tests/examples/test_minimax_h3_examples.py
+++ b/tests/examples/test_minimax_h3_examples.py
@@ -165,7 +165,7 @@ def test_ref2va_manifests_are_ordered_valid_and_dataset_relative() -> None:
for row_index, row in enumerate(rows):
references = row["references"]
canonical = json.loads(canonicalize_reference_manifest(references, row_index=row_index))
- assert [entry["kind"] for entry in canonical] == [entry["kind"] for entry in references]
+ assert [entry["type"] for entry in canonical] == [entry["type"] for entry in references]
for reference in references:
path = Path(reference["path"])
assert not path.is_absolute()
@@ -174,7 +174,7 @@ def test_ref2va_manifests_are_ordered_valid_and_dataset_relative() -> None:
audio_path = Path(reference["audio_path"])
assert not audio_path.is_absolute()
assert (dataset_dir / audio_path).is_file()
- assert [entry["kind"] for entry in rows[0]["references"]] == [
+ assert [entry["type"] for entry in rows[0]["references"]] == [
"image",
"video",
"audio",
diff --git a/tests/models/minimax_h3/test_condition_state.py b/tests/models/minimax_h3/test_condition_state.py
index 57d708283..4077ca32b 100644
--- a/tests/models/minimax_h3/test_condition_state.py
+++ b/tests/models/minimax_h3/test_condition_state.py
@@ -59,7 +59,7 @@ def test_conditioned_adapters_declare_preparer_but_t2va_keeps_identity() -> None
MiniMaxH3T2VAAdapter.preprocess_cache_version,
MiniMaxH3FL2VAAdapter.preprocess_cache_version,
MiniMaxH3Ref2VAAdapter.preprocess_cache_version,
- } == {"minimax-h3-v2"}
+ } == {"minimax-h3-v3"}
def test_ref2va_preparer_realizes_prefix_once_and_routes_owned_contexts(
diff --git a/tests/models/minimax_h3/test_review_fixes.py b/tests/models/minimax_h3/test_review_fixes.py
index 41ce8d9ec..27087dc16 100644
--- a/tests/models/minimax_h3/test_review_fixes.py
+++ b/tests/models/minimax_h3/test_review_fixes.py
@@ -119,13 +119,13 @@ def test_sparse_indices_reject_invalid_type_range_and_duplicates(indices: Any) -
("adapter_class", "extra"),
[
(MiniMaxH3T2VAAdapter, {"images": [["frame"]]}),
- (MiniMaxH3T2VAAdapter, {"references": [[{"kind": "image"}]]}),
+ (MiniMaxH3T2VAAdapter, {"references": [[{"type": "image"}]]}),
(MiniMaxH3FL2VAAdapter, {"videos": [["video"]], "images": [["frame"]]}),
(MiniMaxH3FL2VAAdapter, {"images": [[], []]}),
- (MiniMaxH3Ref2VAAdapter, {"images": [["frame"]], "references": [[{"kind": "image"}]]}),
+ (MiniMaxH3Ref2VAAdapter, {"images": [["frame"]], "references": [[{"type": "image"}]]}),
(
MiniMaxH3Ref2VAAdapter,
- {"audios": [[torch.zeros(1)]], "references": [[{"kind": "image"}]]},
+ {"audios": [[torch.zeros(1)]], "references": [[{"type": "image"}]]},
),
],
)
diff --git a/tests/models/minimax_h3/test_workflow_execution.py b/tests/models/minimax_h3/test_workflow_execution.py
index 334937956..3719af628 100644
--- a/tests/models/minimax_h3/test_workflow_execution.py
+++ b/tests/models/minimax_h3/test_workflow_execution.py
@@ -197,8 +197,10 @@ def test_preprocess_adds_outer_batch_to_arrow_cache_fields(monkeypatch) -> None:
def test_ref_preprocess_builds_ordered_pinned_objects_without_returning_them(monkeypatch) -> None:
constructed: List[Any] = []
- def reference_type(kind: str):
- return lambda **kwargs: constructed.append((kind, kwargs)) or SimpleNamespace(kind=kind)
+ def reference_type(media_type: str):
+ return lambda **kwargs: constructed.append((media_type, kwargs)) or SimpleNamespace(
+ media_type=media_type
+ )
monkeypatch.setattr(
"flow_factory.models.minimax_h3.workflow.require_minimax_h3_support",
@@ -217,16 +219,16 @@ def reference_type(kind: str):
)
adapter = _adapter(MiniMaxH3Ref2VAAdapter)
references = [
- {"kind": "image", "path": "i.png", "media": "image"},
+ {"type": "image", "path": "i.png", "media": "image"},
{
- "kind": "video",
+ "type": "video",
"path": "v.mp4",
"frames": "frames",
"fps": 24.0,
"audio": torch.zeros(2, 8),
"sample_rate": 32000,
},
- {"kind": "audio", "path": "a.wav", "media": torch.ones(1, 8), "sample_rate": 16000},
+ {"type": "audio", "path": "a.wav", "media": torch.ones(1, 8), "sample_rate": 16000},
]
result = adapter.preprocess_func(
@@ -238,10 +240,14 @@ def reference_type(kind: str):
num_frames=5,
)
- assert [kind for kind, _ in constructed] == ["image", "video", "audio"]
+ assert [media_type for media_type, _ in constructed] == ["image", "video", "audio"]
assert constructed[1][1]["frames"] == "frames"
assert "video" not in constructed[1][1]
- assert [ref.kind for ref in encoded_inputs["references"]] == ["image", "video", "audio"]
+ assert [ref.media_type for ref in encoded_inputs["references"]] == [
+ "image",
+ "video",
+ "audio",
+ ]
assert result["reference_manifest"] == ["manifest"]
assert "references" not in result
assert all(not isinstance(value, SimpleNamespace) for value in result.values())
diff --git a/tests/rewards/test_reward_processor_reconstruction.py b/tests/rewards/test_reward_processor_reconstruction.py
index f9fd45574..10abf26a8 100644
--- a/tests/rewards/test_reward_processor_reconstruction.py
+++ b/tests/rewards/test_reward_processor_reconstruction.py
@@ -49,7 +49,7 @@ def test_distributed_group_reward_preserves_sample_reconstruction_fields() -> No
)
sample = MiniMaxH3Ref2VASample(
prompt="A reference-conditioned prompt",
- reference_manifest='[{"kind":"image","path":"condition.png"}]',
+ reference_manifest='[{"type":"image","path":"condition.png"}]',
)
rewards = processor.compute_rewards([sample], store_to_samples=False)
diff --git a/tests/samples/test_ordered_reference_samples.py b/tests/samples/test_ordered_reference_samples.py
index 1e6f3cf22..7b0992f8d 100644
--- a/tests/samples/test_ordered_reference_samples.py
+++ b/tests/samples/test_ordered_reference_samples.py
@@ -21,20 +21,20 @@
def test_reference_manifest_preserves_order_and_canonicalizes_keys() -> None:
references = [
- {"path": "style.png", "kind": "image"},
+ {"path": "style.png", "type": "image"},
{
- "kind": "video",
+ "type": "video",
"path": "motion.mp4",
"fps": 24,
"audio_path": "sound.wav",
"sample_rate": 48000,
},
- {"kind": "audio", "path": "ambience.wav", "sample_rate": 32000},
+ {"type": "audio", "path": "ambience.wav", "sample_rate": 32000},
]
manifest = canonicalize_reference_manifest(references, row_index=3)
- assert [entry["kind"] for entry in parse_reference_manifest(manifest, 3)] == [
+ assert [entry["type"] for entry in parse_reference_manifest(manifest, 3)] == [
"image",
"video",
"audio",
@@ -51,14 +51,14 @@ def test_reference_sample_identity_includes_ordered_manifest() -> None:
first = Ref2AVSample(
prompt="animate",
reference_manifest=canonicalize_reference_manifest(
- [{"kind": "image", "path": "first.png"}],
+ [{"type": "image", "path": "first.png"}],
0,
),
)
second = Ref2AVSample(
prompt="animate",
reference_manifest=canonicalize_reference_manifest(
- [{"kind": "image", "path": "second.png"}],
+ [{"type": "image", "path": "second.png"}],
0,
),
)
@@ -70,12 +70,14 @@ def test_reference_sample_identity_includes_ordered_manifest() -> None:
"references,match",
[
([], "non-empty"),
- ([{"kind": "audio", "path": "only.wav"}], "image or video"),
- ([{"kind": "image", "path": ""}], "non-empty string"),
- ([{"kind": "image", "path": "x.png", "fps": 24}], "unknown keys"),
- ([{"kind": "video", "path": "x.mp4", "fps": float("nan")}], "finite positive"),
+ ([{"path": "missing-type.png"}], "expected type"),
+ ([{"type": "document", "path": "unsupported.pdf"}], "expected type"),
+ ([{"type": "audio", "path": "only.wav"}], "image or video"),
+ ([{"type": "image", "path": ""}], "non-empty string"),
+ ([{"type": "image", "path": "x.png", "fps": 24}], "unknown keys"),
+ ([{"type": "video", "path": "x.mp4", "fps": float("nan")}], "finite positive"),
(
- [{"kind": "video", "path": "x.mp4", "sample_rate": 32000}],
+ [{"type": "video", "path": "x.mp4", "sample_rate": 32000}],
"requires audio_path",
),
],
diff --git a/tests/trainers/test_collective_packing.py b/tests/trainers/test_collective_packing.py
index 75bd90bc8..7ff9766b3 100644
--- a/tests/trainers/test_collective_packing.py
+++ b/tests/trainers/test_collective_packing.py
@@ -117,7 +117,7 @@ def test_gather_samples_packs_same_dtype_fields_and_preserves_other_fields():
def test_gather_samples_preserves_concrete_reconstruction_fields() -> None:
accelerator = GatherRecorder()
- manifest = '[{"kind":"image","path":"condition.png"}]'
+ manifest = '[{"path":"condition.png","type":"image"}]'
sample = MiniMaxH3Ref2VASample(
prompt="A reference-conditioned prompt",
reference_manifest=manifest,
From 826cf599de8f4d79ee29c9792e1ff9139c6b1f20 Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 08:41:12 +0800
Subject: [PATCH 74/76] test(docs): align H3 parameter assertion with main
---
.agents/knowledge/topics/fix_patterns.md | 13 +++++++++++++
tests/docs/test_minimax_h3_docs.py | 4 ++--
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 8eef41d75..66f7899d1 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -771,6 +771,19 @@ Based on the fix type, write the fix entry to the appropriate document:
third-party object-construction boundary and version every cache that stores the old shape.
- **Related Constraint**: #5
+### Rebase conflict resolution includes downstream assertions
+- **Date**: 2026-08-31
+- **Symptom**: After rebasing onto a README correction for the MiniMax H3 parameter count, the
+ merged table was accurate but a child-branch documentation test still required the superseded
+ value.
+- **Root Cause**: The factual conflict was resolved in the edited document, while its regression
+ assertion lived in a cleanly rebased file and therefore received no conflict marker.
+- **Fix**: The documentation assertion now checks the corrected `33B` value inherited from main.
+- **Lesson**: Conflict markers identify overlapping text, not the complete semantic impact of a
+ rebase. After resolving a factual conflict, search for and run downstream assertions that encode
+ the same fact even when Git reports those files as clean.
+- **Related Constraint**: N/A
+
## Cross-refs
- UP: [Hard Constraints](../constraints.md), [Architecture](../architecture.md)
diff --git a/tests/docs/test_minimax_h3_docs.py b/tests/docs/test_minimax_h3_docs.py
index edbe2af12..d166adbcd 100644
--- a/tests/docs/test_minimax_h3_docs.py
+++ b/tests/docs/test_minimax_h3_docs.py
@@ -37,7 +37,7 @@ def test_readme_documents_h3_links_dependency_and_limits() -> None:
assert "all 36 real-weight smoke cells" in text
assert "FL2VA first-plus-last" in text
assert "T2VA is real-weight validated on 1 and 16 GPUs" not in text
- assert text.count("30B | ") == 3
+ assert text.count("33B | ") == 3
for model_type, link in zip(
("minimax-h3-t2va", "minimax-h3-fl2va", "minimax-h3-ref2va"),
@@ -58,7 +58,7 @@ def test_readme_documents_h3_links_dependency_and_limits() -> None:
"data-ward velocity",
"N transitions",
"N + 1 states",
- "30B",
+ "33B",
"do not claim a completed long-run reward trend",
"[Datasets](guidance/datasets.md)",
):
From cc319afae10931a9b09c1c1bbb68415ba9d7860c Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 21:29:27 +0800
Subject: [PATCH 75/76] [deps] chore: require validated Muon runtime stack
---
.agents/knowledge/constraints.md | 4 ++--
.agents/knowledge/dependencies.md | 29 ++++++++++++++++--------
.agents/knowledge/topics/fix_patterns.md | 2 +-
AGENTS.md | 2 +-
README.md | 9 ++++++--
config/accelerate_configs/fsdp2.yaml | 4 ++--
docker/README.md | 2 +-
docker/docker-cuda/Dockerfile | 9 ++++----
pyproject.toml | 18 +++++++--------
scripts/install_geneval_deps.sh | 8 +++----
src/flow_factory/rewards/ocr.py | 16 +++++--------
src/flow_factory/utils/audio.py | 2 +-
12 files changed, 58 insertions(+), 47 deletions(-)
diff --git a/.agents/knowledge/constraints.md b/.agents/knowledge/constraints.md
index 77fcc746b..964c943dc 100644
--- a/.agents/knowledge/constraints.md
+++ b/.agents/knowledge/constraints.md
@@ -203,7 +203,7 @@ The adapter resolves `component_load_dtypes` at native load/materialization time
## Code Quality (21–27)
### 21. Formatting Standards
-- **Black** with `line-length=100`, targeting Python 3.10–3.12
+- **Black** with `line-length=100`, targeting Python 3.11–3.12
- **isort** with `profile="black"`, `line_length=100`
- Comments and docstrings in **English**
@@ -214,7 +214,7 @@ The adapter resolves `component_load_dtypes` at native load/materialization time
- **Top-level imports only**: All `import` / `from ... import ...` statements MUST live at the top of the module, never inside function bodies, methods, `__init__`, or conditional branches. Sanctioned exceptions: (a) optional dependencies wrapped in `try/except ImportError` (e.g., `deepspeed`, `xformers`); (b) backend-gated imports where the target symbol is only resolvable under a specific runtime backend already selected by a preceding feature check (e.g., DeepSpeed/FSDP submodules guarded by `is_deepspeed()` / `is_fsdp2()` in `models/abc.py`); (c) genuine unresolvable circular imports documented inline. Lazy imports added merely for "import speed" or "to keep the module light" are NOT acceptable — every hard dependency already runs through Python's import machinery on a typical import path. Inline imports hide the dependency surface from readers, `isort`, and static-analysis tools, and re-execute on every call in hot loops.
### 23. Type Annotations
-All public methods must have type annotations. Use `typing` module types (`List`, `Dict`, `Optional`, `Tuple`, `Union`) for Python 3.10 compatibility.
+All public methods must have type annotations. Use `typing` module types (`List`, `Dict`, `Optional`, `Tuple`, `Union`) for Python 3.11 compatibility.
### 24. License Header
All source files must include the Apache 2.0 license header with `Copyright 2026 Jayce-Ping`.
diff --git a/.agents/knowledge/dependencies.md b/.agents/knowledge/dependencies.md
index 79198cd3e..a9e25ed11 100644
--- a/.agents/knowledge/dependencies.md
+++ b/.agents/knowledge/dependencies.md
@@ -20,11 +20,11 @@ pip install -e ".[deepspeed]" # Core + DeepSpeed only
pyproject.toml
├── [project.dependencies] Core deps (always installed)
├── [project.optional-dependencies]
-│ ├── deepspeed DeepSpeed >= 0.15.4
+│ ├── deepspeed DeepSpeed >= 0.18.3
│ ├── quantization bitsandbytes >= 0.45.3
│ ├── wandb Weights & Biases tracking
│ ├── swanlab SwanLab tracking
-│ ├── nvidia xformers, nvidia-ml-py
+│ ├── nvidia xformers >= 0.0.35, nvidia-ml-py
│ ├── bagel flash-attn, opencv-python
│ ├── geneval mmcv, mmengine, mmdet, open_clip_torch
│ ├── geneval2 scipy (exact GenEval2 GM parity)
@@ -36,14 +36,15 @@ pyproject.toml
The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ packages). Key ones:
-| Package | Min Version | Purpose |
+| Package | Supported Version | Purpose |
|---------|-------------|---------|
-| `torch` | >= 2.6.0 | PyTorch core and AdamW baseline; Muon needs the optional API noted below |
-| `torchvision` | >= 0.19.0 | Vision utilities |
-| `torchaudio` | >= 2.4.0 | Audio I/O (audio / audio-video models, CLAP) |
+| `torch` | >= 2.10.0 | PyTorch core, AdamW, and the native `torch.optim.Muon` API |
+| `torchvision` | >= 0.25.0 | Vision utilities; paired baseline for PyTorch 2.10 |
+| `torchaudio` | >= 2.10.0 | Audio I/O (audio / audio-video models, CLAP) |
+| `torchcodec` | >= 0.10.0 | Runtime used by TorchAudio 2.10 `load` / `save` APIs |
| `transformers` | >= 4.57.1 | Text encoders, tokenizers |
| `diffusers` | >= 0.40.0 | Diffusion pipelines, schedulers, MiniMax H3 and LTX2 APIs |
-| `accelerate` | >= 1.11.0 | Distributed training, mixed precision |
+| `accelerate` | >= 1.14.0 | Distributed training, mixed precision, and max reduction |
| `peft` | >= 0.17.0 | LoRA, parameter-efficient fine-tuning |
| `datasets` | >= 3.3.2 | Dataset loading |
| `huggingface-hub` | >= 0.35.3 | Model/dataset downloads |
@@ -57,12 +58,18 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
- DeepSpeed is optional — Accelerate alone handles most distributed scenarios.
### Muon
-- The core `torch>=2.6.0` floor does not guarantee `torch.optim.Muon`. Selecting
- `optimizer: muon` requires a build that exposes that API (included in standard releases from
- PyTorch 2.9); runtime capability detection remains authoritative.
+- The `torch>=2.10.0` baseline includes `torch.optim.Muon`. Runtime capability detection remains
+ authoritative for vendor or custom builds that may omit the API.
- Muon is supported with DDP and FSDP2. The pre-load optimizer/backend validator rejects
DeepSpeed and FSDP1 before pretrained weights are loaded.
+### TorchAudio and TorchCodec
+- TorchAudio 2.10 delegates `torchaudio.load` and `torchaudio.save` to TorchCodec. The matching
+ baseline is TorchCodec 0.10, and its native extension also needs FFmpeg 4–8 shared libraries.
+- The CUDA Docker image installs FFmpeg. Non-container installations must provide compatible
+ system FFmpeg libraries; the `imageio[ffmpeg]` executable bundle does not provide those shared
+ libraries.
+
### diffusers
- Use the released `diffusers>=0.40.0` package as the authoritative API. The repository submodule
may be used for upstream development, but must not silently override the declared runtime dependency.
@@ -84,6 +91,8 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
### accelerate
- Primary distributed backend. `accelerator.prepare()` wraps a single `ModelBundle` (all target components) plus the optimizer as one root (constraint #9).
+- Version 1.14 or newer is required because distributed runtime-identity checks use
+ `Accelerator.reduce(..., reduction="max")`; earlier releases do not implement max reduction.
- Online generation uses framework samplers. Finite SFT/offline-DPO data uses PyTorch's official
`DistributedSampler`; that already-sharded loader is not prepared via Accelerate.
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index 66f7899d1..c4dbfe485 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -711,7 +711,7 @@ Based on the fix type, write the fix entry to the appropriate document:
### Optional optimizer APIs must be validated before model loading
- **Date**: 2026-08-31
-- **Symptom**: A Muon configuration on the supported PyTorch 2.6 baseline could pass backend
+- **Symptom**: A Muon configuration on the then-declared PyTorch 2.6 baseline could pass backend
validation, load pretrained weights, and then fail when optimizer construction accessed the
unavailable `torch.optim.Muon` attribute.
- **Root Cause**: Backend validation assumed that parsing a Muon optimizer configuration implied
diff --git a/AGENTS.md b/AGENTS.md
index 2ebfbf74d..025337c7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,7 @@ Flow-Factory is a unified **online and offline fine-tuning framework** for diffu
- **Algorithms**: SFT, offline DPO, online DPO, GRPO, GRPO-Guard, DPPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1
- **Models**: FLUX.1 (+Kontext), FLUX.2 (+Klein), SD3.5, Qwen-Image (+Edit-Plus), Z-Image, Wan2 (T2V/I2V), LTX2 (T2AV/I2AV), MiniMax H3 (T2VA/FL2VA/Ref2VA), Bagel, SenseNova-U1 (1.0/1.5; T2I + ordered multi-reference I2I)
- **Rewards**: PickScore (+Rank), CLIP, CLAP, ImageBind, OCR, GenEval/GenEval2, HPSv2, VLM-Evaluate, rational-rewards, and custom rewards
-- **Python**: >=3.10 | **PyTorch**: >=2.6.0 | **License**: Apache-2.0
+- **Python**: >=3.11 | **PyTorch**: >=2.10.0 | **License**: Apache-2.0
**Language**: Match user's language.
diff --git a/README.md b/README.md
index 2ce49eac1..df2225bdd 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,9 @@ cd Flow-Factory
pip install -e .
```
+Flow-Factory requires Python 3.11 or newer and PyTorch 2.10 or newer. PyTorch 2.10 includes the
+native `torch.optim.Muon` API used by Muon optimizer configs.
+
Optional dependencies, such as `deepspeed`, are also available. Install them with:
```bash
@@ -163,8 +166,10 @@ pip install -e .[deepspeed]
> **Dependency:** MiniMax H3 and LTX2 require the released `diffusers>=0.40.0` API.
> PyAV >=18.0.0 decodes ordered video/audio references and target media.
+> TorchAudio 2.10 delegates audio loading and saving to TorchCodec, which also requires FFmpeg
+> shared libraries. The CUDA image installs those system libraries automatically.
-A CUDA training image (Python 3.12, **uv**-based install, PyTorch 2.8 + `cu129`, `deepspeed`, `wandb`, released `diffusers`) is defined under [`docker/docker-cuda/`](docker/docker-cuda/Dockerfile). See [`docker/README.md`](docker/README.md) for build and run instructions (including `linux/amd64` on Apple Silicon).
+A CUDA training image (Python 3.12, **uv**-based install, PyTorch 2.10 + `cu129`, `deepspeed`, `wandb`, released `diffusers`) is defined under [`docker/docker-cuda/`](docker/docker-cuda/Dockerfile). See [`docker/README.md`](docker/README.md) for build and run instructions (including `linux/amd64` on Apple Silicon).
## Experiment Trackers
@@ -341,7 +346,7 @@ The following reward models are pre-registered and ready to use:
| `rational_rewards_edit` | Pointwise | A reasoning reward model that provides multi-aspect reward for image edit; four aspects → scalar in [0, 1] | [RationalRewards-8B-Edit](https://huggingface.co/TIGER-Lab/RationalRewards-8B-Edit) |
| `qwen_image_bench` | Pointwise | Qwen-Image-Bench "Q-Judger"; hierarchical 5-dim / 56-facet scoring with per-prompt `dims_en` → scalar in [0, 1] | [Qwen-Image-Bench](https://github.com/QwenLM/Qwen-Image-Bench) |
-> **GenEval** requires extra dependencies (mmcv, mmdet, open_clip). Install with: `bash scripts/install_geneval_deps.sh` (Python 3.10 recommended). See [guidance/rewards.md](guidance/rewards.md#dataset-metadata-convention) for dataset format.
+> **GenEval** requires extra dependencies (mmcv, mmdet, open_clip). Install with: `bash scripts/install_geneval_deps.sh` (Python 3.11 or 3.12). See [guidance/rewards.md](guidance/rewards.md#dataset-metadata-convention) for dataset format.
> **VLM-as-Judge** (remote vLLM / OpenAI-style HTTP) is covered in [guidance/rewards.md#vlm-as-judge](guidance/rewards.md#vlm-as-judge) (`vllm_evaluate`, Rational Rewards, `qwen_image_bench`, async tips). For [RationalRewards](https://github.com/TIGER-AI-Lab/RationalRewards) specifically, serve the judge with [`scripts/start_vllm_rational_reward.sh`](scripts/start_vllm_rational_reward.sh) and set YAML `api_base_url` / `vlm_model` to match `--served-model-name` (defaults: `RationalRewards-8B-T2I` / `RationalRewards-8B-Edit`). For [Qwen-Image-Bench](https://github.com/QwenLM/Qwen-Image-Bench), use [`scripts/start_vllm_qwen_image_bench.sh`](scripts/start_vllm_qwen_image_bench.sh) and build the dataset with `python dataset/qwen_image_bench/prepare.py`.
diff --git a/config/accelerate_configs/fsdp2.yaml b/config/accelerate_configs/fsdp2.yaml
index 7a8701ad7..296aebca5 100644
--- a/config/accelerate_configs/fsdp2.yaml
+++ b/config/accelerate_configs/fsdp2.yaml
@@ -4,7 +4,7 @@ downcast_bf16: 'no'
fsdp_config:
fsdp_version: 2
fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
- # Accelerate 1.13 FSDP2 always uses original DTensor parameters and normalizes
+ # Accelerate 1.14+ FSDP2 always uses original DTensor parameters and normalizes
# use_orig_params to None. Do not add the obsolete fsdp_use_orig_params key.
# FSDP2 also rejects any explicit forward_prefetch value, including false.
fsdp_offload_params: false
@@ -23,4 +23,4 @@ same_network: true
tpu_env: []
tpu_use_cluster: false
tpu_use_sudo: false
-use_cpu: false
\ No newline at end of file
+use_cpu: false
diff --git a/docker/README.md b/docker/README.md
index ced5c85de..4df1fbe34 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,6 +1,6 @@
# Docker (CUDA) — Flow-Factory Training Image
-Pre-built GPU training image for Flow-Factory: CUDA 12.9, Python 3.12, PyTorch 2.8, DeepSpeed, and W&B — ready to run `ff-train` out of the box.
+Pre-built GPU training image for Flow-Factory: CUDA 12.9, Python 3.12, PyTorch 2.10, DeepSpeed, and W&B — ready to run `ff-train` out of the box.
## Prerequisites
diff --git a/docker/docker-cuda/Dockerfile b/docker/docker-cuda/Dockerfile
index 91d63bdbf..8c61e9b98 100644
--- a/docker/docker-cuda/Dockerfile
+++ b/docker/docker-cuda/Dockerfile
@@ -46,9 +46,10 @@ RUN mkdir -p /app/src/flow_factory && touch /app/src/flow_factory/__init__.py
ARG PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu129
RUN uv pip install \
- torch==2.8.0 \
- torchvision==0.23.0 \
- torchaudio==2.8.0 \
+ torch==2.10.0 \
+ torchvision==0.25.0 \
+ torchaudio==2.10.0 \
+ torchcodec==0.10.0 \
--index-url "${PYTORCH_INDEX_URL}"
RUN uv pip install ".[deepspeed,wandb]"
@@ -58,7 +59,7 @@ COPY . /app
RUN uv pip install --no-deps -e .
# Fail fast if any layer is broken
-RUN python -c "import deepspeed, diffusers, torch, wandb; assert tuple(map(int, diffusers.__version__.split('.')[:2])) >= (0, 40); print('torch', torch.__version__, 'diffusers', diffusers.__version__)" && ff-train --help >/dev/null
+RUN python -c "import deepspeed, diffusers, torch, torchcodec, wandb; assert hasattr(torch.optim, 'Muon'); assert tuple(map(int, diffusers.__version__.split('.')[:2])) >= (0, 40); print('torch', torch.__version__, 'torchcodec', torchcodec.__version__, 'diffusers', diffusers.__version__)" && ff-train --help >/dev/null
# Intentionally root: DeepSpeed / NCCL may need elevated IPC access.
CMD ["/bin/bash"]
diff --git a/pyproject.toml b/pyproject.toml
index 7957a5024..7d4917844 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ name = "flow-factory"
version = "0.1.0"
description = "Unified RL Fine-tuning Framework for Diffusion/Flow-Matching Models"
readme = "README.md"
-requires-python = ">=3.10"
+requires-python = ">=3.11"
license = {text = "Apache-2.0"}
authors = [
{name = "Flow-Factory Team"}
@@ -17,7 +17,6 @@ classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
- "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
@@ -25,14 +24,15 @@ classifiers = [
# Core
dependencies = [
# Deep Learning Core
- "torch>=2.6.0",
- "torchvision>=0.19.0",
- "torchaudio>=2.4.0",
+ "torch>=2.10.0",
+ "torchvision>=0.25.0",
+ "torchaudio>=2.10.0",
+ "torchcodec>=0.10.0",
# Hugging Face Ecosystem
"transformers>=4.57.1", # Compatible with both v4.x and v5.x
"diffusers>=0.40.0",
- "accelerate>=1.11.0",
+ "accelerate>=1.14.0",
"peft>=0.17.0",
"datasets>=3.3.2",
"tokenizers>=0.22.1",
@@ -58,7 +58,7 @@ dependencies = [
[project.optional-dependencies]
# DeepSpeed (ZeRO optimization)
deepspeed = [
- "deepspeed>=0.15.4",
+ "deepspeed>=0.18.3",
]
# Quantization (8-bit/4-bit)
@@ -80,7 +80,7 @@ wandb = [ "wandb" ]
swanlab = [ "swanlab" ]
nvidia = [
- "xformers>=0.0.27",
+ "xformers>=0.0.35",
"nvidia-ml-py",
]
@@ -115,7 +115,7 @@ where = ["src"]
[tool.black]
line-length = 100
-target-version = ['py310', 'py311', 'py312']
+target-version = ['py311', 'py312']
[tool.isort]
profile = "black"
diff --git a/scripts/install_geneval_deps.sh b/scripts/install_geneval_deps.sh
index 8644172da..b9f2f4c71 100644
--- a/scripts/install_geneval_deps.sh
+++ b/scripts/install_geneval_deps.sh
@@ -4,8 +4,8 @@
# Install GenEval reward model dependencies (mmcv + mmdet + open_clip)
#
# Requirements:
-# - Python 3.10 or 3.12 (tested)
-# - PyTorch >= 2.0 with CUDA
+# - Python 3.11 or 3.12
+# - PyTorch >= 2.10 with CUDA
# - CUDA toolkit (nvcc) for mmcv CUDA ops compilation
# - uv (recommended) or pip
#
@@ -39,8 +39,8 @@ fi
PY_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
-if [[ "$PY_VERSION" != "3.10" && "$PY_VERSION" != "3.12" ]]; then
- warn "Python ${PY_VERSION} detected. This script has only been tested with Python 3.10 and 3.12."
+if [[ "$PY_VERSION" != "3.11" && "$PY_VERSION" != "3.12" ]]; then
+ warn "Python ${PY_VERSION} detected. Flow-Factory supports Python 3.11 and 3.12."
warn "Proceeding anyway..."
echo ""
fi
diff --git a/src/flow_factory/rewards/ocr.py b/src/flow_factory/rewards/ocr.py
index 33feea173..853f65e33 100644
--- a/src/flow_factory/rewards/ocr.py
+++ b/src/flow_factory/rewards/ocr.py
@@ -20,8 +20,10 @@
pip install paddlepaddle-gpu==3.3.0 -i https://www.paddlepaddle.org.cn/packages/stable/cu129/
pip install paddleocr
pip install python-Levenshtein
-# Install torch2.8.0 and it will update nvcc toolkits automatically
-pip install torch==2.8.0 torchvision==0.23.0 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu129
+# Install the project baseline PyTorch stack and its audio codec runtime
+pip install \
+ torch==2.10.0 torchvision==0.25.0 torchaudio==2.10.0 torchcodec==0.10.0 \
+ --index-url https://download.pytorch.org/whl/cu129
# Maybe you will need this:
yum install -y mesa-libGL glib2
```
@@ -83,9 +85,7 @@ def _compute_scores_batch(
metadata: Optional[list[str]] = None,
) -> list[float]:
"""Compute mean target-text fidelity for each image."""
- if len(prompt) != len(image) or (
- metadata is not None and len(prompt) != len(metadata)
- ):
+ if len(prompt) != len(image) or (metadata is not None and len(prompt) != len(metadata)):
raise ValueError(
"expected equal OCR batch lengths for prompt and image, with optional "
"metadata matching that length; "
@@ -132,11 +132,7 @@ def _targets_from_prompt(prompt: str, sample_index: int) -> list[str]:
f"expected string prompt for OCR sample {sample_index}, "
f"received {type(prompt).__name__}: {prompt!r}"
)
- targets = [
- target.strip()
- for target in _QUOTED_TEXT.findall(prompt)
- if target.strip()
- ]
+ targets = [target.strip() for target in _QUOTED_TEXT.findall(prompt) if target.strip()]
if not targets:
raise ValueError(
f"expected quoted OCR target in prompt for sample {sample_index}, "
diff --git a/src/flow_factory/utils/audio.py b/src/flow_factory/utils/audio.py
index 924b9d4ce..fc7dcdfa7 100644
--- a/src/flow_factory/utils/audio.py
+++ b/src/flow_factory/utils/audio.py
@@ -243,7 +243,7 @@ def load_audio(
Note:
Backend resolution (see :func:`_load_audio_backend`):
1. ``torchaudio`` — primary backend, handles wav/mp3/flac/ogg/...
- (``torchaudio>=2.4.0`` is a core dependency).
+ (``torchaudio>=2.10.0`` and its TorchCodec runtime are core dependencies).
2. ``soundfile`` — used when ``torchaudio`` is unavailable;
handles wav/flac/ogg.
3. stdlib ``wave`` — last-resort fallback, WAV-only,
From 93e4a935d049ec7701350be14286bdb998ac8a9b Mon Sep 17 00:00:00 2001
From: Jayce-Ping <315229706@qq.com>
Date: Mon, 31 Aug 2026 21:50:18 +0800
Subject: [PATCH 76/76] [deps] fix: restore Python 3.10 compatibility
---
.agents/knowledge/constraints.md | 4 ++--
.agents/knowledge/dependencies.md | 7 +++++++
.agents/knowledge/topics/fix_patterns.md | 13 +++++++++++++
.agents/knowledge/topics/minimax_h3.md | 2 +-
AGENTS.md | 2 +-
README.md | 6 +++---
dataset/minimax_h3_ref2va/README.md | 2 +-
guidance/datasets.md | 2 +-
pyproject.toml | 7 ++++---
scripts/install_geneval_deps.sh | 6 +++---
src/flow_factory/data_utils/dataset.py | 4 ++--
src/flow_factory/data_utils/offline_dataset.py | 4 ++--
tests/docs/test_minimax_h3_docs.py | 2 +-
13 files changed, 41 insertions(+), 20 deletions(-)
diff --git a/.agents/knowledge/constraints.md b/.agents/knowledge/constraints.md
index 964c943dc..77fcc746b 100644
--- a/.agents/knowledge/constraints.md
+++ b/.agents/knowledge/constraints.md
@@ -203,7 +203,7 @@ The adapter resolves `component_load_dtypes` at native load/materialization time
## Code Quality (21–27)
### 21. Formatting Standards
-- **Black** with `line-length=100`, targeting Python 3.11–3.12
+- **Black** with `line-length=100`, targeting Python 3.10–3.12
- **isort** with `profile="black"`, `line_length=100`
- Comments and docstrings in **English**
@@ -214,7 +214,7 @@ The adapter resolves `component_load_dtypes` at native load/materialization time
- **Top-level imports only**: All `import` / `from ... import ...` statements MUST live at the top of the module, never inside function bodies, methods, `__init__`, or conditional branches. Sanctioned exceptions: (a) optional dependencies wrapped in `try/except ImportError` (e.g., `deepspeed`, `xformers`); (b) backend-gated imports where the target symbol is only resolvable under a specific runtime backend already selected by a preceding feature check (e.g., DeepSpeed/FSDP submodules guarded by `is_deepspeed()` / `is_fsdp2()` in `models/abc.py`); (c) genuine unresolvable circular imports documented inline. Lazy imports added merely for "import speed" or "to keep the module light" are NOT acceptable — every hard dependency already runs through Python's import machinery on a typical import path. Inline imports hide the dependency surface from readers, `isort`, and static-analysis tools, and re-execute on every call in hot loops.
### 23. Type Annotations
-All public methods must have type annotations. Use `typing` module types (`List`, `Dict`, `Optional`, `Tuple`, `Union`) for Python 3.11 compatibility.
+All public methods must have type annotations. Use `typing` module types (`List`, `Dict`, `Optional`, `Tuple`, `Union`) for Python 3.10 compatibility.
### 24. License Header
All source files must include the Apache 2.0 license header with `Copyright 2026 Jayce-Ping`.
diff --git a/.agents/knowledge/dependencies.md b/.agents/knowledge/dependencies.md
index a9e25ed11..c84df2886 100644
--- a/.agents/knowledge/dependencies.md
+++ b/.agents/knowledge/dependencies.md
@@ -45,6 +45,7 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
| `transformers` | >= 4.57.1 | Text encoders, tokenizers |
| `diffusers` | >= 0.40.0 | Diffusion pipelines, schedulers, MiniMax H3 and LTX2 APIs |
| `accelerate` | >= 1.14.0 | Distributed training, mixed precision, and max reduction |
+| `av` | >= 17.0.0 | CPU video/audio decoding with Python 3.10 support |
| `peft` | >= 0.17.0 | LoRA, parameter-efficient fine-tuning |
| `datasets` | >= 3.3.2 | Dataset loading |
| `huggingface-hub` | >= 0.35.3 | Model/dataset downloads |
@@ -70,6 +71,12 @@ The authoritative list is `pyproject.toml` `[project.dependencies]` (20+ package
system FFmpeg libraries; the `imageio[ffmpeg]` executable bundle does not provide those shared
libraries.
+### PyAV
+- The tested floor is `av>=17.0.0`. Python 3.10 resolves to a compatible 17.x release because
+ PyAV 18 requires Python 3.11; Python 3.11 and newer may resolve to later PyAV releases.
+- Flow-Factory uses the container, stream, frame, and resampler APIs available in PyAV 17; it does
+ not require a PyAV 18-only API.
+
### diffusers
- Use the released `diffusers>=0.40.0` package as the authoritative API. The repository submodule
may be used for upstream development, but must not silently override the declared runtime dependency.
diff --git a/.agents/knowledge/topics/fix_patterns.md b/.agents/knowledge/topics/fix_patterns.md
index c4dbfe485..bcfab0d20 100644
--- a/.agents/knowledge/topics/fix_patterns.md
+++ b/.agents/knowledge/topics/fix_patterns.md
@@ -784,6 +784,19 @@ Based on the fix type, write the fix entry to the appropriate document:
the same fact even when Git reports those files as clean.
- **Related Constraint**: N/A
+### Dependency floors must preserve declared Python compatibility
+- **Date**: 2026-08-31
+- **Symptom**: Aligning the runtime metadata with `av>=18.0.0` raised Flow-Factory's Python floor
+ from 3.10 to 3.11 even though the framework and its Muon dependency stack still supported 3.10.
+- **Root Cause**: The media-decoder floor followed the latest tested PyAV release without checking
+ whether Flow-Factory used a PyAV 18-only API or whether PyAV 17 covered the same contract.
+- **Fix**: Restore `requires-python>=3.10`, set the tested decoder floor to `av>=17.0.0`, and align
+ classifiers, formatter targets, runtime errors, installation guidance, and agent documentation.
+- **Lesson**: A dependency-induced interpreter-floor increase is not automatically a framework
+ requirement. Verify the used API surface and test the last compatible dependency line before
+ dropping a supported Python version.
+- **Related Constraint**: N/A
+
## Cross-refs
- UP: [Hard Constraints](../constraints.md), [Architecture](../architecture.md)
diff --git a/.agents/knowledge/topics/minimax_h3.md b/.agents/knowledge/topics/minimax_h3.md
index bde33e60c..7da450e11 100644
--- a/.agents/knowledge/topics/minimax_h3.md
+++ b/.agents/knowledge/topics/minimax_h3.md
@@ -53,7 +53,7 @@ and lets that boundary stay strict.
geometry cache fields and a preprocessing cache version.
- Reference paths are dataset-relative. Positive finite `fps` and `sample_rate` overrides follow
`samples/references.py`.
-- PyAV >=18.0.0 decodes video/audio references, including embedded or separate soundtracks.
+- PyAV >=17.0.0 decodes video/audio references, including embedded or separate soundtracks.
## Offline audiovisual output contract
diff --git a/AGENTS.md b/AGENTS.md
index 025337c7f..14aecb73b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,7 @@ Flow-Factory is a unified **online and offline fine-tuning framework** for diffu
- **Algorithms**: SFT, offline DPO, online DPO, GRPO, GRPO-Guard, DPPO, DGPO, DiffusionNFT, AWM, CRD, DiffusionOPD, DMD2, TDM, TDM-R1
- **Models**: FLUX.1 (+Kontext), FLUX.2 (+Klein), SD3.5, Qwen-Image (+Edit-Plus), Z-Image, Wan2 (T2V/I2V), LTX2 (T2AV/I2AV), MiniMax H3 (T2VA/FL2VA/Ref2VA), Bagel, SenseNova-U1 (1.0/1.5; T2I + ordered multi-reference I2I)
- **Rewards**: PickScore (+Rank), CLIP, CLAP, ImageBind, OCR, GenEval/GenEval2, HPSv2, VLM-Evaluate, rational-rewards, and custom rewards
-- **Python**: >=3.11 | **PyTorch**: >=2.10.0 | **License**: Apache-2.0
+- **Python**: >=3.10 | **PyTorch**: >=2.10.0 | **License**: Apache-2.0
**Language**: Match user's language.
diff --git a/README.md b/README.md
index df2225bdd..0c29b8227 100644
--- a/README.md
+++ b/README.md
@@ -153,7 +153,7 @@ cd Flow-Factory
pip install -e .
```
-Flow-Factory requires Python 3.11 or newer and PyTorch 2.10 or newer. PyTorch 2.10 includes the
+Flow-Factory requires Python 3.10 or newer and PyTorch 2.10 or newer. PyTorch 2.10 includes the
native `torch.optim.Muon` API used by Muon optimizer configs.
Optional dependencies, such as `deepspeed`, are also available. Install them with:
@@ -165,7 +165,7 @@ pip install -e .[deepspeed]
> **Note**: The Bagel adapter requires `flash-attn` (>= 2.5.8) and `opencv-python`. Install them with `pip install -e .[bagel]` (the `[bagel]` extra is intentionally not part of `[all]` because flash-attn is heavy to build).
> **Dependency:** MiniMax H3 and LTX2 require the released `diffusers>=0.40.0` API.
-> PyAV >=18.0.0 decodes ordered video/audio references and target media.
+> PyAV >=17.0.0 decodes ordered video/audio references and target media.
> TorchAudio 2.10 delegates audio loading and saving to TorchCodec, which also requires FFmpeg
> shared libraries. The CUDA image installs those system libraries automatically.
@@ -346,7 +346,7 @@ The following reward models are pre-registered and ready to use:
| `rational_rewards_edit` | Pointwise | A reasoning reward model that provides multi-aspect reward for image edit; four aspects → scalar in [0, 1] | [RationalRewards-8B-Edit](https://huggingface.co/TIGER-Lab/RationalRewards-8B-Edit) |
| `qwen_image_bench` | Pointwise | Qwen-Image-Bench "Q-Judger"; hierarchical 5-dim / 56-facet scoring with per-prompt `dims_en` → scalar in [0, 1] | [Qwen-Image-Bench](https://github.com/QwenLM/Qwen-Image-Bench) |
-> **GenEval** requires extra dependencies (mmcv, mmdet, open_clip). Install with: `bash scripts/install_geneval_deps.sh` (Python 3.11 or 3.12). See [guidance/rewards.md](guidance/rewards.md#dataset-metadata-convention) for dataset format.
+> **GenEval** requires extra dependencies (mmcv, mmdet, open_clip). Install with: `bash scripts/install_geneval_deps.sh` (Python 3.10 or newer). See [guidance/rewards.md](guidance/rewards.md#dataset-metadata-convention) for dataset format.
> **VLM-as-Judge** (remote vLLM / OpenAI-style HTTP) is covered in [guidance/rewards.md#vlm-as-judge](guidance/rewards.md#vlm-as-judge) (`vllm_evaluate`, Rational Rewards, `qwen_image_bench`, async tips). For [RationalRewards](https://github.com/TIGER-AI-Lab/RationalRewards) specifically, serve the judge with [`scripts/start_vllm_rational_reward.sh`](scripts/start_vllm_rational_reward.sh) and set YAML `api_base_url` / `vlm_model` to match `--served-model-name` (defaults: `RationalRewards-8B-T2I` / `RationalRewards-8B-Edit`). For [Qwen-Image-Bench](https://github.com/QwenLM/Qwen-Image-Bench), use [`scripts/start_vllm_qwen_image_bench.sh`](scripts/start_vllm_qwen_image_bench.sh) and build the dataset with `python dataset/qwen_image_bench/prepare.py`.
diff --git a/dataset/minimax_h3_ref2va/README.md b/dataset/minimax_h3_ref2va/README.md
index c85df671b..d4dfa5684 100644
--- a/dataset/minimax_h3_ref2va/README.md
+++ b/dataset/minimax_h3_ref2va/README.md
@@ -18,7 +18,7 @@ At least one image or video is required; audio-only manifests are invalid. A vid
is valid only when that video also supplies `audio_path`. Manifest `fps` and `sample_rate`
overrides take precedence over decoded metadata where supported.
-PyAV >=18.0.0 is required for reliable video/audio decoding. It preserves video frames and FPS,
+PyAV >=17.0.0 is required for reliable video/audio decoding. It preserves video frames and FPS,
plus embedded or separately referenced audio and its sample rate. Only encoded tensors/layout and
the canonical manifest enter the Arrow cache; upstream reference objects are transient.
diff --git a/guidance/datasets.md b/guidance/datasets.md
index 782fea196..aa3efffed 100644
--- a/guidance/datasets.md
+++ b/guidance/datasets.md
@@ -114,7 +114,7 @@ must carry the supervision type required by its trainer. Prompt-only rows, mixed
preference rows, unknown keys, and non-V2 records fail during manifest loading.
All V2 media paths are resolved against that source's `dataset_dir`; an absolute path is retained.
-Images, videos, and audio have built-in CPU decoders. Video targets require PyAV 18 or newer.
+Images, videos, and audio have built-in CPU decoders. Video targets require PyAV 17 or newer.
Decoded audio is a detached CPU `float32` waveform shaped `(channels, samples)`. A manifest
`sample_rate` is a logical source-clock override and does not pre-resample the decoded samples;
source-clock truncation, channel conversion, the single model-rate conversion, posterior selection,
diff --git a/pyproject.toml b/pyproject.toml
index 7d4917844..a86efc613 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ name = "flow-factory"
version = "0.1.0"
description = "Unified RL Fine-tuning Framework for Diffusion/Flow-Matching Models"
readme = "README.md"
-requires-python = ">=3.11"
+requires-python = ">=3.10"
license = {text = "Apache-2.0"}
authors = [
{name = "Flow-Factory Team"}
@@ -17,6 +17,7 @@ classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
+ "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
@@ -44,7 +45,7 @@ dependencies = [
"ftfy", # Required by Diffusers Wan prompt normalization
# Basic Utils
- "av>=18.0.0",
+ "av>=17.0.0",
"imageio[ffmpeg]>=2.37.2",
"numpy>=1.26.4",
"pillow>=10.4.0",
@@ -115,7 +116,7 @@ where = ["src"]
[tool.black]
line-length = 100
-target-version = ['py311', 'py312']
+target-version = ['py310', 'py311', 'py312']
[tool.isort]
profile = "black"
diff --git a/scripts/install_geneval_deps.sh b/scripts/install_geneval_deps.sh
index b9f2f4c71..e22d4017c 100644
--- a/scripts/install_geneval_deps.sh
+++ b/scripts/install_geneval_deps.sh
@@ -4,7 +4,7 @@
# Install GenEval reward model dependencies (mmcv + mmdet + open_clip)
#
# Requirements:
-# - Python 3.11 or 3.12
+# - Python >= 3.10
# - PyTorch >= 2.10 with CUDA
# - CUDA toolkit (nvcc) for mmcv CUDA ops compilation
# - uv (recommended) or pip
@@ -39,8 +39,8 @@ fi
PY_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
-if [[ "$PY_VERSION" != "3.11" && "$PY_VERSION" != "3.12" ]]; then
- warn "Python ${PY_VERSION} detected. Flow-Factory supports Python 3.11 and 3.12."
+if ! python -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)"; then
+ warn "Python ${PY_VERSION} detected. Flow-Factory requires Python 3.10 or newer."
warn "Proceeding anyway..."
echo ""
fi
diff --git a/src/flow_factory/data_utils/dataset.py b/src/flow_factory/data_utils/dataset.py
index 69330ba96..deb46378c 100644
--- a/src/flow_factory/data_utils/dataset.py
+++ b/src/flow_factory/data_utils/dataset.py
@@ -1422,8 +1422,8 @@ def _require_finite_positive_rate(
def _require_pyav() -> Any:
if av is None:
raise ImportError(
- "ordered video/audio references require PyAV>=18.0.0; "
- "install with `pip install 'av>=18.0.0'`"
+ "ordered video/audio references require PyAV>=17.0.0; "
+ "install with `pip install 'av>=17.0.0'`"
)
return av
diff --git a/src/flow_factory/data_utils/offline_dataset.py b/src/flow_factory/data_utils/offline_dataset.py
index 767257ffd..c6f80dcd5 100644
--- a/src/flow_factory/data_utils/offline_dataset.py
+++ b/src/flow_factory/data_utils/offline_dataset.py
@@ -485,8 +485,8 @@ def decode_video(asset: MediaAsset) -> np.ndarray:
"""
if av is None:
raise ImportError(
- "offline target video decoding requires PyAV>=18.0.0; "
- "install with `pip install 'av>=18.0.0'`"
+ "offline target video decoding requires PyAV>=17.0.0; "
+ "install with `pip install 'av>=17.0.0'`"
)
try:
with av.open(asset.path) as container:
diff --git a/tests/docs/test_minimax_h3_docs.py b/tests/docs/test_minimax_h3_docs.py
index d166adbcd..8d0bdf0a9 100644
--- a/tests/docs/test_minimax_h3_docs.py
+++ b/tests/docs/test_minimax_h3_docs.py
@@ -50,7 +50,7 @@ def test_readme_documents_h3_links_dependency_and_limits() -> None:
for required in (
"diffusers>=0.40.0",
"pip install -e .",
- "PyAV >=18.0.0",
+ "PyAV >=17.0.0",
"B=1",
"no CFG",
"shift 12",