Files
hao-ai-lab-FastVideo/v2/program/workflow.py
T
SolitaryThinker 431f4daddb [feat] Adapter plane, non-linear workflows, RL→distill flywheel
Three more capabilities on distinct untested surfaces (167 tests pass, no new runtime primitive).

A — Adapter plane (§9.19): one base + swappable LoRA/ControlNet adapters, selected per request
(DiffusionParams.adapters); AdapterDenoiseLoop applies each active adapter's velocity delta. Per-request
selection changes output, multi-LoRA composes, ControlNet conditions on a control image, mixed-adapter
requests interleave without smearing, hot-swap changes generation, cache key partitions by adapter stack
(the adapter_versions field, previously declared-only). ToyLoRA/ToyControlNet; models/adapters/. 6 tests.

B — Non-linear workflows (§9.17): ParallelWorkflow (fan-out: one input → N models → merged) and
BestOfNWorkflow (generate N → score with the served reward card → return best; inference-time scaling).
The shapes a linear chain can't express. 4 tests.

D — RL→distill flywheel (§9.18): run_flywheel RL-improves the base (NFT), then distills FROM the RL'd model
(DMD2 teacher = RL'd policy) into a faster card, recording the base→rl→distilled provenance chain in
RecipeSpec.parents. The distilled student is measurably closer to the RL'd teacher than the base; the
distilled card serves few-step. training/flywheel.py. 4 tests.

designv4 §9.17–§9.19 + layout/closing/counts (167 tests, 29 files).
2026-07-04 17:15:34 +00:00

187 lines
8.9 KiB
Python

"""Workflow — multi-MODEL composition above the engine (design_v3 §13; ``ProgramKind.WORKFLOW``).
A ``Program`` composes the loops of ONE resident model instance (the omni/MoT default — every
``ModelLoopNode`` resolves on the program's single instance). A ``Workflow`` composes *across model
instances*: each stage is a full ``engine.run`` on a (possibly different) registered card, with typed
artifacts threaded stage→stage. This is the realistic "T2I model **followed by** I2V model" pipeline
(FLUX→Wan): two distinct cards, two distinct weight sets, chained.
The split mirrors the doc's two layers exactly:
* **Program** — single-model, step-interleaved loop composition (the hot path; unchanged).
* **Workflow** — multi-model orchestration; a thin layer that calls ``engine.run`` per stage and
passes artifacts forward. No change to the engine's single-instance program runner — so cross-
model chaining costs nothing on the per-step scheduling path. The engine already holds every
instance in its registry; the Workflow just selects which card each stage runs on.
Why not put cross-model in ``Program``? Because a step-interleaved program shares one resident
instance, one cache scope, one parity gate. Crossing instances is a *boundary* (different weights,
different caches, an artifact hand-off) — exactly a Workflow edge, not a loop step. Keeping it out of
the program runner is what preserves the interleave-parity guarantee for the single-model case.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
@dataclass
class WorkflowStage:
"""One model invocation. ``make_request(state)`` builds this stage's Request from the accumulated
``state`` (the initial inputs plus every prior stage's artifacts, keyed ``"<stage_label>:<name>"``
and also under ``"prev"`` for the immediately-preceding stage's artifact dict)."""
model_id: str
make_request: Callable[[dict[str, Any]], Any]
label: str = ""
def _engine_serves(engine: Any, model_id: str) -> bool:
if hasattr(engine, "serves"):
return engine.serves(model_id) # AsyncEngine
return model_id in getattr(engine, "_registry", {}) # Engine
@dataclass
class Workflow:
"""A named, registrable cross-model pipeline. ``workflow_id`` is its servable name — it lives in
the SAME namespace as a card's ``model_id`` (so the engine, server, and fleet route to it the same
way), and by convention is dotted/namespaced to avoid colliding with a card id (e.g.
``"image_video.t2i_i2v"``). ``requires`` is the set of cards it composes — declared, validated,
never inferred (design.md P7)."""
workflow_id: str
stages: list[WorkflowStage] = field(default_factory=list)
@property
def requires(self) -> list[str]:
"""The model_ids this workflow composes (deduped, in first-use order). The engine must serve
all of them before the workflow can run."""
seen: set[str] = set()
out: list[str] = []
for s in self.stages:
if s.model_id not in seen:
seen.add(s.model_id)
out.append(s.model_id)
return out
def validate(self, engine: Any) -> "Workflow":
"""Fail-fast: every required card must already be registered on ``engine`` (no silent skips)."""
if not self.stages:
raise ValueError(f"workflow {self.workflow_id!r} has no stages")
missing = [m for m in self.requires if not _engine_serves(engine, m)]
if missing:
raise ValueError(f"workflow {self.workflow_id!r} requires unregistered models {missing} "
f"(register those cards before the workflow)")
return self
def run(self, engine: Any, **initial: Any) -> Any:
"""Execute the stages in order on ``engine``; return the final stage's Output.
Each stage's artifacts are merged into ``state`` so downstream stages can read them
(e.g. the I2V stage reads the T2I stage's ``image`` artifact)."""
if not self.stages:
raise ValueError(f"workflow {self.workflow_id!r} has no stages")
state: dict[str, Any] = dict(initial)
out = None
for stage in self.stages:
label = stage.label or stage.model_id
req = stage.make_request(state)
if req.model_id != stage.model_id:
raise ValueError(f"workflow {self.workflow_id!r} stage {label!r}: request model "
f"{req.model_id!r} != stage model {stage.model_id!r}")
out = engine.run(req)
state["prev"] = out.artifacts
for name, art in out.artifacts.items():
state[f"{label}:{name}"] = art
return out
def _payload(artifact: Any) -> Any:
for attr in ("frames", "samples", "latent", "tensor", "text"):
v = getattr(artifact, attr, None)
if v is not None:
return v
return None
class ParallelWorkflow(Workflow):
"""Fan-out: run every stage on the SAME initial input (conceptually in parallel — the engine can
interleave their steps), then merge their artifacts into one Output, namespaced by stage label.
The non-linear shape a linear chain can't express: one prompt → N models → a merged result (e.g.
variants, or video+audio+upscale in parallel). Design_v3 §13."""
def run(self, engine: Any, **initial: Any) -> Any:
if not self.stages:
raise ValueError(f"workflow {self.workflow_id!r} has no stages")
from ..request.artifacts import Output
merged: dict[str, Any] = {}
metrics: dict[str, float] = {}
rid = self.workflow_id
for stage in self.stages:
label = stage.label or stage.model_id
req = stage.make_request(dict(initial)) # each branch sees the ORIGINAL input
if req.model_id != stage.model_id:
raise ValueError(f"{self.workflow_id!r} stage {label!r}: model mismatch")
out = engine.run(req)
rid = out.request_id
for name, art in out.artifacts.items():
merged[f"{label}:{name}"] = art # namespaced — branches don't collide
for k, v in out.metrics.items():
metrics[f"{label}:{k}"] = v
return Output(request_id=rid, artifacts=merged, metrics=metrics)
class BestOfNWorkflow(Workflow):
"""Inference-time scaling / rejection sampling: generate N candidates (varying seed), score each with
a reward scorer, return the best. A feedback loop across models — generator + reward (e.g. the served
REWARD_BATCH card, §9.15) — the other non-linear shape. Design_v3 §10/§13."""
def __init__(self, workflow_id, generator_stage, *, scorer, n: int = 4, score_key: str = "latents"):
super().__init__(workflow_id, [generator_stage])
self.scorer = scorer # any .score(media, prompts) -> {"avg": ...}
self.n = int(n)
self.score_key = score_key
def run(self, engine: Any, **initial: Any) -> Any:
import numpy as np
stage = self.stages[0]
base_seed = int(initial.get("seed", 0))
cands = []
for i in range(self.n):
req = stage.make_request({**initial, "seed": base_seed * 100 + i})
if req.model_id != stage.model_id:
raise ValueError(f"{self.workflow_id!r}: model mismatch")
cands.append(engine.run(req))
media = [_payload(c.artifacts[self.score_key]) for c in cands]
scores = np.asarray(self.scorer.score(media, [initial.get("prompt", "")] * len(cands))["avg"])
best = int(np.argmax(scores))
out = cands[best]
out.metrics["best_of_n"] = float(self.n)
out.metrics["best_index"] = float(best)
out.metrics["best_score"] = float(scores[best])
return out
class WorkflowRegistry:
"""Declarative ``workflow_id → builder`` catalog — the cross-model analog of the card builders in
``models/__init__.py`` (cf. vllm-omni's ``pipeline_registry``). Adding a custom pipeline is one
``register`` call; the builder is a zero/kw-arg factory returning a ``Workflow``."""
def __init__(self) -> None:
self._builders: dict[str, Any] = {}
def register(self, workflow_id: str, builder: Any) -> Any:
if workflow_id in self._builders:
raise ValueError(f"workflow {workflow_id!r} already registered")
self._builders[workflow_id] = builder
return builder
def build(self, workflow_id: str, **kw: Any) -> Workflow:
if workflow_id not in self._builders:
raise KeyError(f"no workflow {workflow_id!r} (have {list(self._builders)})")
return self._builders[workflow_id](**kw)
def names(self) -> list[str]:
return list(self._builders)
def __contains__(self, workflow_id: str) -> bool:
return workflow_id in self._builders