Add Model Bundle
Start Here
When adding a diffusion or autoregressive model, first inspect unirl/models/README.md, unirl/models/types/, and the closest package under unirl/models/:
unirl/models/sd3/: image diffusion with text embeddings, CFG, VAE decode, and driver-provided initial latents.
unirl/models/wan21/: text/image-to-video diffusion with image latent and CLIP-vision conditioning.
unirl/models/wan22/: text-to-video diffusion.
unirl/models/flux2_klein/ and unirl/models/qwen_image/: image diffusion families with model-specific text/token conditioning.
unirl/models/hunyuan_video15/: video diffusion with multiple text/vision encoders.
unirl/models/hunyuan_image3/: mixed AR and diffusion topology for multimodal tasks.
unirl/models/qwen3/: pure causal-LM AR package.
unirl/models/qwen_vl/ and unirl/models/pe/: additional package-specific patterns when relevant.
The current architecture is a typed pipeline:
EncodeStage[P, C] / EmbedStage[P, C] convert primitives to conditions, DiffusionStage[C] / ARStage[C] produce segments, DecodeStage[S, P] decodes segments, and Pipeline.generate(sample: Sample) -> Sample fills pre-forked generation Parts.
Bundle in unirl/models/types/bundle.py is an intentionally empty Remote subclass. Concrete bundles are plain weight holders; LoRA injection, FSDP wrapping, adapter switching, offload, and autocast lifecycle are owned outside the bundle.
Implementation Checklist
- Create
unirl/models/<model_name>/ rather than a single file. Typical files are __init__.py, config.py, bundle.py, pipeline.py, conditions.py, diffusion.py or ar.py, plus text_embed.py, vae.py, and vision helpers as needed.
- In
config.py, define <Model>PipelineConfig as a plain @dataclass. Recipes reference it by _target_: unirl.models.<model_name>.<Model>PipelineConfig (nested under the bundle/pipeline config: block) — no registration.
- Include config fields that match the package's real needs: checkpoint paths,
model_precision, auxiliary dtype fields, runtime device, autocast_precision, trajectory_precision, logprob_precision, schedule knobs such as shift for FlowMatch diffusion, weight_sync_param_name_prefix, use_lora, and lora_target_modules.
- In
bundle.py, implement <Model>Bundle as a plain class with from_config(config). Load transformer, VAE, text encoders, vision encoders, tokenizers, processors, and schedulers as needed. Use parse_torch_dtype(..., field_name=...) for dtype fields, place the trainable module on the requested device and dtype, and freeze auxiliary modules with requires_grad_(False).
- In
conditions.py, implement <Model>Conditions(Batch) with typed condition slots and from_dict(d) / to_dict(). Validate required slots, reject wrong types with actionable errors, and omit None optional slots from the outgoing dict.
- Add embed/encode stages for inputs:
EmbedStage[Texts, TextEmbedCondition], EncodeStage[Images, ImageLatentCondition], or model-specific variants. Keep tokenization, chat templates, text encoder fusion, image preprocessing, and upstream-compatible negative prompt defaults in these stages or in the pipeline that calls them.
- For diffusion models, add
<Model>DiffusionStep(DiffusionStep[<Model>Bundle, <Model>Conditions]). By local convention, it should expose predict_noise(...) for per-step transformer invocation, CFG batching, timestep scaling, condition concat, masks, and private third-party kwargs. Delegate SDE math to the supplied StepStrategy.
- Add
<Model>DiffusionStage(DiffusionStage[<Model>Conditions]). It owns latent initialization when supported by the package, the diffusion loop, trajectory storage, replay, precision policy, and trainable_module() when training-side injection needs the trainable root. Declare _no_split_modules on the stage when diffusers modules need FSDP wrapping hints.
- For AR models, add
<Model>ARStep and <Model>ARStage(ARStage[<Model>Conditions]) instead of diffusion step/stage classes. Follow unirl/models/qwen3/ar.py for packed TextSegment generation and replay.
- In
vae.py or equivalent, implement DecodeStage[LatentSegment, Images | Videos] and any required EncodeStage[Images | Videos, ImageLatentCondition]. Apply the model's VAE scale, shift, dtype, layout, frame, and clamp conventions.
- In
pipeline.py, implement <Model>Pipeline(Pipeline) with from_config(...) and generate(sample). Read raw inputs through sample.conditioning(), read sampling params from the typed generation Part, require params.sigmas for diffusion, call stages in order, and fill that Part with segment, modality-keyed primitives, and conditions.
- Add
latent_shape(cls, *, model_config, sampling_spec) when the driver should author a deterministic initial-noise recipe on the generation Part for group noise or resume behavior.
- Update the package
__init__.py to import and export public symbols from config.py, bundle.py, pipeline.py, and condition classes so importing unirl.models.<model_name> re-exports them.
- Add at least one recipe YAML under
examples/<domain>/ (the v2 config dir, grouped by trainer domain) and document external checkpoint requirements there or in launcher environment docs.
Wiring Touchpoints
Model packages are wired into recipes by _target_ dotpath (no ConfigStore):
- Define
<Model>PipelineConfig as a plain @dataclass in config.py.
- Recipes set
bundle._target_: ...<Model>Bundle.from_config with a nested config._target_: ...<Model>PipelineConfig; the worker walker constructs them.
- Add new shared condition types under
unirl/types/conditions/ only when existing slots cannot express the semantics; export them from unirl/types/conditions/__init__.py.
- Add or update rollout-engine model-family enums only when the model is served through an engine that explicitly enumerates families, such as SGLang or vLLM-Omni configs.
Keep package-specific logic under unirl/models/<model_name>/. Put only cross-model protocols or reusable condition abstractions under unirl/models/types/ or unirl/types/conditions/.
Meta-Init (avoid the per-rank full-model load)
A bundle can build its trainable transformer on the meta device instead of eager from_pretrained, so the backend materializes + loads weights after sharding — avoiding the per-rank full-model GPU/host spike that OOMs large models. Both FSDPBackend and VeOmniBackend honor a single contract; opt in per recipe with meta_init_transformer: true on the bundle config (default false → unchanged eager path).
Single-transformer bundles (the common case) branch in from_config:
if config.meta_init_transformer:
weights_path = resolve_meta_init_weights(path, component="transformer")
transformer_config = <Class>.load_config(path, subfolder="transformer") # diffusers
transformer, meta_init_state = build_meta_init_transformer( # unirl.models.types.meta_init
lambda: <Class>.from_config(transformer_config),
dtype=dtype,
)
else:
transformer = <Class>.from_pretrained(path, subfolder="transformer", torch_dtype=dtype).to(device, dtype=dtype)
...
bundle = cls(...)
if config.meta_init_transformer:
bundle._transformer_weights_path = weights_path
bundle._meta_init_state = meta_init_state
return bundle
resolve_meta_init_weights passes local snapshots through, resolves Hub IDs into the shared cache, and validates complete root/component safetensors shards.
build_meta_init_transformer builds under accelerate.init_empty_weights(include_buffers=False), captures init-computed non-persistent buffers/plain tensor attributes, dtype-casts (metadata-only on meta), and stamps init_weights to a no-op. Stash its returned state on bundle._meta_init_state; the backend restores it after the sharded weight load, including across Ray actor serialization.
- Stash
_transformer_weights_path from resolve_meta_init_weights; it is the local safetensors directory consumed by load_sharded (unirl/train/backend/sharded_load.py).
- AR/VL checkpoint-root layouts call
resolve_meta_init_weights(path) without component, then use an AutoModelForCausalLM.from_config(...) (qwen3) or ModelClass(cfg) (qwen_vl) factory. Structural setup that does not touch weights (gradient_checkpointing_enable, requires_grad_(False) for a frozen vision tower) runs on both builds and persists through to_empty + load.
Per-architecture init-computed state that to_empty destroys is handled by the shared capture/restore path. build_meta_init_transformer deliberately keeps buffers and __dict__ tensors real on CPU while parameters are meta, and raises if a captured tensor is unexpectedly still meta. This covers plain-tensor rope tables (Qwen-Image QwenEmbedRope.pos_freqs) and non-persistent sincos buffers (SD3 PatchEmbed.pos_embed) without bespoke rebuild/deferred-stamp helpers.
- params the checkpoint omits (FLUX.2-klein guidance embedder): zero-init them post-load via a deferred op keyed on checkpoint-absent names —
to_empty leaves them as garbage (not meta), so an is_meta-gated fix won't catch them.
Always confirm parity on a GPU pod: the meta build must load weights byte-identical to the eager path, on both backends.
Composite trainables with embedded frozen aux (only hunyuan_image3 today — transformer.vae / transformer.vision_model live inside the meta-built wrapper) are the exception, and add two hooks on top of the contract:
trainable_module() returns the bare decoder (transformer.model). The backend resolves the module to wrap/optimize/checkpoint via resolve_trainable_module(bundle, trainable_attr) (unirl/train/backend/base.py), which prefers this method and otherwise falls back to the named attr. Handing the backend the single decoder — not the heterogeneous composite — keeps the frozen aux outside the wrap (on meta until materialized, off the optimizer/checkpoint), which is what lets the composite run under VeOmni (its parallelize root-shards + whole-root-to_emptys its input) and makes the weight_sync_param_name_prefix ("model.") resolve correctly.
- a self-contained
materialize(device, with_aux=()) allocates + DCP-loads the decoder, the always-resident diffusion heads, and the opt-in vae/vit in one collective (mixed DTensor decoder + plain heads/aux); the backend calls it when no _transformer_weights_path is stashed.
Single-transformer bundles whose aux are separate eager modules need none of that.
Conditions And Field Kinds
<Model>Conditions(Batch) is the typed container passed to diffusion or AR stages and serialized through Part.conditions. It owns conditioning slots only. Latents live in LatentSegment; sigma schedules live in the generation Part's DiffusionSamplingParams.sigmas and segment metadata.
Use field kinds from unirl/distributed/tensor/batch.py:
field(kind=FieldKind.CONCAT, transport=True, default=None): per-sample, batch-aligned slots such as text, negative text, image latents, image embeddings, and masks.
field(kind=FieldKind.SHARED, transport=False, default=None) or shared_field(...): batch-shared metadata such as static position grids or spatial shape lists.
concat_field(...) and shared_field(...) are available helper aliases, but the generic field(...) form is the most explicit when transport metadata matters.
Reuse existing condition slot types before adding new ones:
TextEmbedCondition(embeds, pooled, attn_mask): frozen text-encoder hidden states, optional pooled head, and optional attention mask.
TextTokenCondition(input_ids, attention_mask): token IDs and masks for models whose transformer owns token embeddings.
ImageEmbedCondition(embeds, attn_mask, spatial_shapes): CLIP/SigLIP/ViT-style image features.
ImageLatentCondition(latents): VAE-encoded image or video conditioning latents.
FusedMultimodalCondition(...): interleaved text/image token payloads for omni-style bundles.
Keep slot names semantic and flat: text / negative_text, image_latent, image_embed, prompt, etc. Do not reuse a slot name with different meaning, and do not hide CFG branches inside another condition object.
Negative Prompt And CFG
CFG belongs in the diffusion step, with the pipeline and embed stages preparing positive and negative conditions:
- The pipeline validates prompt and negative prompt batch sizes.
- If upstream behavior requires CFG negatives and none were supplied, the pipeline should create the upstream-compatible empty negative primitive before embedding, such as
"" for SD3 or the model-specific canonical empty string for Qwen-style pipelines.
<Model>DiffusionStep.predict_noise(...) should batch unconditional and conditional branches, run one transformer call, chunk outputs, and combine uncond + guidance_scale * (cond - uncond).
- If
negative_text is absent but guidance_scale > 1.0, either raise a clear error or use the package's established fallback, such as zero-init negative embeddings in SD3. Match the model's existing or upstream behavior explicitly.
DiffusionStage Rules
<Model>DiffusionStage.diffuse(...) owns the rollout loop and LatentSegment assembly:
- Use
schedule=params.sigmas from the generation Part; diffusion pipelines should raise if it is None.
- Do not build sigma schedules inside the pipeline or stage. Hosting engines pin schedules onto the Sample before calling
generate(sample).
- Validate schedule length against the requested step count.
- Initialize latents from request-provided
initial_latents when the package supports deterministic driver-side noise; otherwise call the repository noise helper used by the closest template.
- Store trajectories at
unirl.types.sampling.compute_trajectory_positions(...) plus the final clean latent position, with stored latents in trajectory_precision and log-probs in logprob_precision.
- Keep direct transformer calls inside
<Model>DiffusionStep.predict_noise(...). The stage should call self.step.step(...) or self.step.step_with_logp(...).
- Implement
replay(...) to recompute log-probs and previous-sample means from stored LatentSegment transitions for training.
- Implement
predict_noise_at_step(conditions, *, sample, sigma, params) for forward-process algorithms such as DiffusionNFT; it should delegate to the same predict_noise(...) path so CFG and guidance behavior match diffuse(...) and replay(...).
- Expose
trainable_module() and _no_split_modules on the stage when training-side injection or wrapping needs the trainable root or FSDP hints.
ARStage Rules
For causal-LM or multimodal AR paths:
- Use
ARStage[<Model>Conditions] and ARStep from unirl/models/types/ar.py.
autoregress(...) should produce a packed TextSegment with generated tokens, masks or lengths, and per-token log-probs aligned with replay.
replay(...) should recompute log-probs for stored rollout tokens with the same tokenization and attention-mask semantics.
- Expose
trainable_module() when training-side LoRA/FSDP injection needs the wrapped transformer root.
- Use
ARSamplingParams for common generation controls and a package-specific params dataclass only for model-specific knobs.
What To Verify
Follow the CLAUDE.md verification-harness rule: run small, uncommitted CPU
harnesses with fakes or monkeypatches and quote the commands and results in the
PR's Test Plan.
- Conditions:
from_dict / to_dict round trips, optional slots, wrong-typed slot errors, and missing required slot errors.
- Diffusion step: CFG batching (uncond+cond in one transformer call), timestep scaling, masks, vision kwargs, and private transformer kwargs, using fake transformers.
- Pipeline wiring: construct fake stages, call
generate(sample), and assert lineage preservation, generation-Part conditions/segment/primitives, and pinned-sigma validation (generate must raise when params.sigmas is None).
- AR models: generation and replay token/log-prob alignment on a tiny fake model.
- Recipe/config wiring: instantiate added configs and recipes with lightweight fakes and verify their
_target_ paths resolve.
Review Before Finishing
<Model>PipelineConfig is a plain @dataclass; recipes reference it (and <Model>Pipeline.from_config) by _target_.
- The package
__init__.py re-exports the config / pipeline classes.
Pipeline.generate(sample) validates required conditioning primitives, typed generation params, negative prompt batch sizes, and pinned sigmas for diffusion.
- Filled generation Parts use canonical primitive keys such as
"image", "video", "audio", or "text", and include conditions and a segment when available.
<Model>Conditions.from_dict and to_dict are symmetric and fail loudly for wrong or missing required slots.
- Per-sample tensors use
FieldKind.CONCAT; shared metadata uses FieldKind.SHARED.
- The diffusion stage owns loop bookkeeping, trajectory storage, replay, and precision casts; the diffusion step owns transformer calls and CFG math.
- The sigma schedule is consumed from the generation Part's params; it is not rebuilt in the model package.
- Bundle loading normalizes dtype/device, freezes auxiliary modules, and keeps trainable module naming compatible with
weight_sync_param_name_prefix.
- LoRA target modules are explicit for production models;
None is only used deliberately.
- Recipe YAML exists under
examples/<domain>/ (the v2 config dir, grouped by trainer domain) and documents required checkpoints or environment variables.
1---2name: add-model-bundle3description: Add or update UniRL model package support. Use when adding diffusion or autoregressive model pipelines, model config dataclasses, Bundle/Pipeline/Stage/Conditions implementations, LoRA targets, FSDP wrapping hints, Sample/Part plumbing, or multimodal text/image/video/audio conditioning.4---56# Add Model Bundle78## Start Here910When adding a diffusion or autoregressive model, first inspect `unirl/models/README.md`, `unirl/models/types/`, and the closest package under `unirl/models/`:1112- `unirl/models/sd3/`: image diffusion with text embeddings, CFG, VAE decode, and driver-provided initial latents.13- `unirl/models/wan21/`: text/image-to-video diffusion with image latent and CLIP-vision conditioning.14- `unirl/models/wan22/`: text-to-video diffusion.15- `unirl/models/flux2_klein/` and `unirl/models/qwen_image/`: image diffusion families with model-specific text/token conditioning.16- `unirl/models/hunyuan_video15/`: video diffusion with multiple text/vision encoders.17- `unirl/models/hunyuan_image3/`: mixed AR and diffusion topology for multimodal tasks.18- `unirl/models/qwen3/`: pure causal-LM AR package.19- `unirl/models/qwen_vl/` and `unirl/models/pe/`: additional package-specific patterns when relevant.2021The current architecture is a typed pipeline:2223`EncodeStage[P, C]` / `EmbedStage[P, C]` convert primitives to conditions, `DiffusionStage[C]` / `ARStage[C]` produce segments, `DecodeStage[S, P]` decodes segments, and `Pipeline.generate(sample: Sample) -> Sample` fills pre-forked generation Parts.2425`Bundle` in `unirl/models/types/bundle.py` is an intentionally empty `Remote` subclass. Concrete bundles are plain weight holders; LoRA injection, FSDP wrapping, adapter switching, offload, and autocast lifecycle are owned outside the bundle.2627## Implementation Checklist28291. Create `unirl/models/<model_name>/` rather than a single file. Typical files are `__init__.py`, `config.py`, `bundle.py`, `pipeline.py`, `conditions.py`, `diffusion.py` or `ar.py`, plus `text_embed.py`, `vae.py`, and vision helpers as needed.302. In `config.py`, define `<Model>PipelineConfig` as a plain `@dataclass`. Recipes reference it by `_target_: unirl.models.<model_name>.<Model>PipelineConfig` (nested under the bundle/pipeline `config:` block) — no registration.313. Include config fields that match the package's real needs: checkpoint paths, `model_precision`, auxiliary dtype fields, runtime `device`, `autocast_precision`, `trajectory_precision`, `logprob_precision`, schedule knobs such as `shift` for FlowMatch diffusion, `weight_sync_param_name_prefix`, `use_lora`, and `lora_target_modules`.324. In `bundle.py`, implement `<Model>Bundle` as a plain class with `from_config(config)`. Load transformer, VAE, text encoders, vision encoders, tokenizers, processors, and schedulers as needed. Use `parse_torch_dtype(..., field_name=...)` for dtype fields, place the trainable module on the requested device and dtype, and freeze auxiliary modules with `requires_grad_(False)`.335. In `conditions.py`, implement `<Model>Conditions(Batch)` with typed condition slots and `from_dict(d)` / `to_dict()`. Validate required slots, reject wrong types with actionable errors, and omit `None` optional slots from the outgoing dict.346. Add embed/encode stages for inputs: `EmbedStage[Texts, TextEmbedCondition]`, `EncodeStage[Images, ImageLatentCondition]`, or model-specific variants. Keep tokenization, chat templates, text encoder fusion, image preprocessing, and upstream-compatible negative prompt defaults in these stages or in the pipeline that calls them.357. For diffusion models, add `<Model>DiffusionStep(DiffusionStep[<Model>Bundle, <Model>Conditions])`. By local convention, it should expose `predict_noise(...)` for per-step transformer invocation, CFG batching, timestep scaling, condition concat, masks, and private third-party kwargs. Delegate SDE math to the supplied `StepStrategy`.368. Add `<Model>DiffusionStage(DiffusionStage[<Model>Conditions])`. It owns latent initialization when supported by the package, the diffusion loop, trajectory storage, replay, precision policy, and `trainable_module()` when training-side injection needs the trainable root. Declare `_no_split_modules` on the stage when diffusers modules need FSDP wrapping hints.379. For AR models, add `<Model>ARStep` and `<Model>ARStage(ARStage[<Model>Conditions])` instead of diffusion step/stage classes. Follow `unirl/models/qwen3/ar.py` for packed `TextSegment` generation and replay.3810. In `vae.py` or equivalent, implement `DecodeStage[LatentSegment, Images | Videos]` and any required `EncodeStage[Images | Videos, ImageLatentCondition]`. Apply the model's VAE scale, shift, dtype, layout, frame, and clamp conventions.3911. In `pipeline.py`, implement `<Model>Pipeline(Pipeline)` with `from_config(...)` and `generate(sample)`. Read raw inputs through `sample.conditioning()`, read sampling params from the typed generation Part, require `params.sigmas` for diffusion, call stages in order, and fill that Part with `segment`, modality-keyed `primitives`, and `conditions`.4012. Add `latent_shape(cls, *, model_config, sampling_spec)` when the driver should author a deterministic initial-noise recipe on the generation Part for group noise or resume behavior.4113. Update the package `__init__.py` to import and export public symbols from `config.py`, `bundle.py`, `pipeline.py`, and condition classes so importing `unirl.models.<model_name>` re-exports them.4214. Add at least one recipe YAML under `examples/<domain>/` (the v2 config dir, grouped by trainer domain) and document external checkpoint requirements there or in launcher environment docs.4344## Wiring Touchpoints4546Model packages are wired into recipes by `_target_` dotpath (no ConfigStore):4748- Define `<Model>PipelineConfig` as a plain `@dataclass` in `config.py`.49- Recipes set `bundle._target_: ...<Model>Bundle.from_config` with a nested `config._target_: ...<Model>PipelineConfig`; the worker walker constructs them.50- Add new shared condition types under `unirl/types/conditions/` only when existing slots cannot express the semantics; export them from `unirl/types/conditions/__init__.py`.51- Add or update rollout-engine model-family enums only when the model is served through an engine that explicitly enumerates families, such as SGLang or vLLM-Omni configs.5253Keep package-specific logic under `unirl/models/<model_name>/`. Put only cross-model protocols or reusable condition abstractions under `unirl/models/types/` or `unirl/types/conditions/`.5455## Meta-Init (avoid the per-rank full-model load)5657A bundle can build its trainable transformer on the **meta** device instead of eager `from_pretrained`, so the backend materializes + loads weights *after* sharding — avoiding the per-rank full-model GPU/host spike that OOMs large models. Both `FSDPBackend` and `VeOmniBackend` honor a single contract; opt in per recipe with `meta_init_transformer: true` on the bundle config (default `false` → unchanged eager path).5859Single-transformer bundles (the common case) branch in `from_config`:6061```python62if config.meta_init_transformer:63 weights_path = resolve_meta_init_weights(path, component="transformer")64 transformer_config = <Class>.load_config(path, subfolder="transformer") # diffusers65 transformer, meta_init_state = build_meta_init_transformer( # unirl.models.types.meta_init66 lambda: <Class>.from_config(transformer_config),67 dtype=dtype,68 )69else:70 transformer = <Class>.from_pretrained(path, subfolder="transformer", torch_dtype=dtype).to(device, dtype=dtype)71...72bundle = cls(...)73if config.meta_init_transformer:74 bundle._transformer_weights_path = weights_path75 bundle._meta_init_state = meta_init_state76return bundle77```7879- `resolve_meta_init_weights` passes local snapshots through, resolves Hub IDs into the shared cache, and validates complete root/component safetensors shards.80- `build_meta_init_transformer` builds under `accelerate.init_empty_weights(include_buffers=False)`, captures init-computed non-persistent buffers/plain tensor attributes, dtype-casts (metadata-only on meta), and stamps `init_weights` to a no-op. Stash its returned state on `bundle._meta_init_state`; the backend restores it after the sharded weight load, including across Ray actor serialization.81- Stash `_transformer_weights_path` from `resolve_meta_init_weights`; it is the local safetensors directory consumed by `load_sharded` (`unirl/train/backend/sharded_load.py`).82- AR/VL checkpoint-root layouts call `resolve_meta_init_weights(path)` without `component`, then use an `AutoModelForCausalLM.from_config(...)` (qwen3) or `ModelClass(cfg)` (qwen_vl) factory. Structural setup that does not touch weights (`gradient_checkpointing_enable`, `requires_grad_(False)` for a frozen vision tower) runs on both builds and persists through `to_empty` + load.8384Per-architecture init-computed state that `to_empty` destroys is handled by the shared capture/restore path. `build_meta_init_transformer` deliberately keeps buffers and `__dict__` tensors real on CPU while parameters are meta, and raises if a captured tensor is unexpectedly still meta. This covers plain-tensor rope tables (Qwen-Image `QwenEmbedRope.pos_freqs`) and non-persistent sincos buffers (SD3 `PatchEmbed.pos_embed`) without bespoke rebuild/deferred-stamp helpers.85- params the checkpoint omits (FLUX.2-klein guidance embedder): zero-init them post-load via a deferred op keyed on checkpoint-absent names — `to_empty` leaves them as garbage (not meta), so an `is_meta`-gated fix won't catch them.8687Always confirm parity on a GPU pod: the meta build must load weights byte-identical to the eager path, on both backends.8889Composite trainables with *embedded* frozen aux (only `hunyuan_image3` today — `transformer.vae` / `transformer.vision_model` live inside the meta-built wrapper) are the exception, and add two hooks on top of the contract:9091- `trainable_module()` returns the bare decoder (`transformer.model`). The backend resolves the module to wrap/optimize/checkpoint via `resolve_trainable_module(bundle, trainable_attr)` (`unirl/train/backend/base.py`), which prefers this method and otherwise falls back to the named attr. Handing the backend the single decoder — not the heterogeneous composite — keeps the frozen aux *outside* the wrap (on meta until materialized, off the optimizer/checkpoint), which is what lets the composite run under VeOmni (its `parallelize` root-shards + whole-root-`to_empty`s its input) and makes the `weight_sync_param_name_prefix` (`"model."`) resolve correctly.92- a self-contained `materialize(device, with_aux=())` allocates + DCP-loads the decoder, the always-resident diffusion heads, and the opt-in vae/vit in one collective (mixed DTensor decoder + plain heads/aux); the backend calls it when no `_transformer_weights_path` is stashed.9394Single-transformer bundles whose aux are separate eager modules need none of that.9596## Conditions And Field Kinds9798`<Model>Conditions(Batch)` is the typed container passed to diffusion or AR stages and serialized through `Part.conditions`. It owns conditioning slots only. Latents live in `LatentSegment`; sigma schedules live in the generation Part's `DiffusionSamplingParams.sigmas` and segment metadata.99100Use field kinds from `unirl/distributed/tensor/batch.py`:101102- `field(kind=FieldKind.CONCAT, transport=True, default=None)`: per-sample, batch-aligned slots such as text, negative text, image latents, image embeddings, and masks.103- `field(kind=FieldKind.SHARED, transport=False, default=None)` or `shared_field(...)`: batch-shared metadata such as static position grids or spatial shape lists.104- `concat_field(...)` and `shared_field(...)` are available helper aliases, but the generic `field(...)` form is the most explicit when transport metadata matters.105106Reuse existing condition slot types before adding new ones:107108- `TextEmbedCondition(embeds, pooled, attn_mask)`: frozen text-encoder hidden states, optional pooled head, and optional attention mask.109- `TextTokenCondition(input_ids, attention_mask)`: token IDs and masks for models whose transformer owns token embeddings.110- `ImageEmbedCondition(embeds, attn_mask, spatial_shapes)`: CLIP/SigLIP/ViT-style image features.111- `ImageLatentCondition(latents)`: VAE-encoded image or video conditioning latents.112- `FusedMultimodalCondition(...)`: interleaved text/image token payloads for omni-style bundles.113114Keep slot names semantic and flat: `text` / `negative_text`, `image_latent`, `image_embed`, `prompt`, etc. Do not reuse a slot name with different meaning, and do not hide CFG branches inside another condition object.115116## Negative Prompt And CFG117118CFG belongs in the diffusion step, with the pipeline and embed stages preparing positive and negative conditions:119120- The pipeline validates prompt and negative prompt batch sizes.121- If upstream behavior requires CFG negatives and none were supplied, the pipeline should create the upstream-compatible empty negative primitive before embedding, such as `""` for SD3 or the model-specific canonical empty string for Qwen-style pipelines.122- `<Model>DiffusionStep.predict_noise(...)` should batch unconditional and conditional branches, run one transformer call, chunk outputs, and combine `uncond + guidance_scale * (cond - uncond)`.123- If `negative_text` is absent but `guidance_scale > 1.0`, either raise a clear error or use the package's established fallback, such as zero-init negative embeddings in SD3. Match the model's existing or upstream behavior explicitly.124125## DiffusionStage Rules126127`<Model>DiffusionStage.diffuse(...)` owns the rollout loop and `LatentSegment` assembly:128129- Use `schedule=params.sigmas` from the generation Part; diffusion pipelines should raise if it is `None`.130- Do not build sigma schedules inside the pipeline or stage. Hosting engines pin schedules onto the Sample before calling `generate(sample)`.131- Validate schedule length against the requested step count.132- Initialize latents from request-provided `initial_latents` when the package supports deterministic driver-side noise; otherwise call the repository noise helper used by the closest template.133- Store trajectories at `unirl.types.sampling.compute_trajectory_positions(...)` plus the final clean latent position, with stored latents in `trajectory_precision` and log-probs in `logprob_precision`.134- Keep direct transformer calls inside `<Model>DiffusionStep.predict_noise(...)`. The stage should call `self.step.step(...)` or `self.step.step_with_logp(...)`.135- Implement `replay(...)` to recompute log-probs and previous-sample means from stored `LatentSegment` transitions for training.136- Implement `predict_noise_at_step(conditions, *, sample, sigma, params)` for forward-process algorithms such as DiffusionNFT; it should delegate to the same `predict_noise(...)` path so CFG and guidance behavior match `diffuse(...)` and `replay(...)`.137- Expose `trainable_module()` and `_no_split_modules` on the stage when training-side injection or wrapping needs the trainable root or FSDP hints.138139## ARStage Rules140141For causal-LM or multimodal AR paths:142143- Use `ARStage[<Model>Conditions]` and `ARStep` from `unirl/models/types/ar.py`.144- `autoregress(...)` should produce a packed `TextSegment` with generated tokens, masks or lengths, and per-token log-probs aligned with replay.145- `replay(...)` should recompute log-probs for stored rollout tokens with the same tokenization and attention-mask semantics.146- Expose `trainable_module()` when training-side LoRA/FSDP injection needs the wrapped transformer root.147- Use `ARSamplingParams` for common generation controls and a package-specific params dataclass only for model-specific knobs.148149## What To Verify150151Follow the `CLAUDE.md` verification-harness rule: run small, uncommitted CPU152harnesses with fakes or monkeypatches and quote the commands and results in the153PR's Test Plan.154155- **Conditions**: `from_dict` / `to_dict` round trips, optional slots, wrong-typed slot errors, and missing required slot errors.156- **Diffusion step**: CFG batching (uncond+cond in one transformer call), timestep scaling, masks, vision kwargs, and private transformer kwargs, using fake transformers.157- **Pipeline wiring**: construct fake stages, call `generate(sample)`, and assert lineage preservation, generation-Part conditions/segment/primitives, and pinned-sigma validation (`generate` must raise when `params.sigmas is None`).158- **AR models**: generation and replay token/log-prob alignment on a tiny fake model.159- **Recipe/config wiring**: instantiate added configs and recipes with lightweight fakes and verify their `_target_` paths resolve.160161## Review Before Finishing162163- `<Model>PipelineConfig` is a plain `@dataclass`; recipes reference it (and `<Model>Pipeline.from_config`) by `_target_`.164- The package `__init__.py` re-exports the config / pipeline classes.165- `Pipeline.generate(sample)` validates required conditioning primitives, typed generation params, negative prompt batch sizes, and pinned sigmas for diffusion.166- Filled generation Parts use canonical primitive keys such as `"image"`, `"video"`, `"audio"`, or `"text"`, and include conditions and a segment when available.167- `<Model>Conditions.from_dict` and `to_dict` are symmetric and fail loudly for wrong or missing required slots.168- Per-sample tensors use `FieldKind.CONCAT`; shared metadata uses `FieldKind.SHARED`.169- The diffusion stage owns loop bookkeeping, trajectory storage, replay, and precision casts; the diffusion step owns transformer calls and CFG math.170- The sigma schedule is consumed from the generation Part's params; it is not rebuilt in the model package.171- Bundle loading normalizes dtype/device, freezes auxiliary modules, and keeps trainable module naming compatible with `weight_sync_param_name_prefix`.172- LoRA target modules are explicit for production models; `None` is only used deliberately.173- Recipe YAML exists under `examples/<domain>/` (the v2 config dir, grouped by trainer domain) and documents required checkpoints or environment variables.