From 76dcb13910a8c8cb83c3cf932b2e18afbcbf2aee Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 19:34:16 +0800 Subject: [PATCH 1/8] fix(lint): close the boundary-checker blind spots found in post-merge review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cross-package rule now catches 'from experimental import ', bare 'import experimental', relative imports climbing past the package root, and imports from top-level experimental/*.py (no shared space) - core-dependency set now unions [project.dependencies], every [project.optional-dependencies] extra, and [tool.uv] override-dependencies — extras pins (torch & co) no longer sail through - requirements scan covers requirements*.txt, rejects pip option/include lines and unparseable URL/VCS requirements (fail closed) - check_recipe_targets gains the tier-direction rule: recipes outside experimental/ may not target experimental.*, and a package's recipes may not target a sibling package - reward extras requirements: drop the (now-flagged) bare torchvision re-pin in videoalign, document that face/videoalign get torch(vision) from the core stack; README states which rules are script-enforced --- experimental/README.md | 7 ++ .../refl/reward/face/requirements.txt | 3 + .../refl/reward/videoalign/requirements.txt | 5 +- lint/check_experimental_boundaries.py | 106 +++++++++++++----- lint/check_recipe_targets.py | 19 ++++ 5 files changed, 114 insertions(+), 26 deletions(-) diff --git a/experimental/README.md b/experimental/README.md index 86fae5f76..b9c6cf78a 100644 --- a/experimental/README.md +++ b/experimental/README.md @@ -54,6 +54,13 @@ graduates into core instead of being borrowed sideways. 6. **Model/engine variation goes through `_target_` polymorphism** — an `if model_family == ...` branch in package code is a review reject. +Rules 1, 2, and 4 are script-enforced (`lint/check_experimental_boundaries.py`; +`lint/check_recipe_targets.py`, which also rejects recipes outside this tier +targeting `experimental.*` and recipes targeting a sibling package). Rules 3, +5, and 6 are enforced in review. The boundary hook scans the working tree, so +uncommitted `private_*` packages are lint-gated locally — their only gate, as +CI never sees them. + ## Graduation Both directions are deliberate PRs, never drive-bys. Up: a second diff --git a/experimental/refl/reward/face/requirements.txt b/experimental/refl/reward/face/requirements.txt index 329432f90..bc560ab33 100644 --- a/experimental/refl/reward/face/requirements.txt +++ b/experimental/refl/reward/face/requirements.txt @@ -1,3 +1,6 @@ +# Face reward extras — ADDITIVE ONLY on top of the UniRL core stack. +# torch / torchvision (used by face_tools.py) come from the core engine extra +# (pyproject.toml); additive-only forbids re-declaring them here. imageio>=2.31 imageio-ffmpeg>=0.4 onnx>=1.14 diff --git a/experimental/refl/reward/videoalign/requirements.txt b/experimental/refl/reward/videoalign/requirements.txt index bcd1c63c0..502114e8a 100644 --- a/experimental/refl/reward/videoalign/requirements.txt +++ b/experimental/refl/reward/videoalign/requirements.txt @@ -6,4 +6,7 @@ # transformers / peft / safetensors / huggingface-hub / einops (see # pyproject.toml); never re-pin those here. Attention runs SDPA — flash-attn # is not part of the locked stack. -torchvision # frame preprocessing transforms; matches the engine extra's torch build +# +# torchvision (frame preprocessing) also comes from the core engine extra — +# additive-only forbids re-declaring it, and a bare re-pin could resolve a +# cu-mismatched wheel that downgrades torch itself. Nothing extra to install. diff --git a/lint/check_experimental_boundaries.py b/lint/check_experimental_boundaries.py index 58a61b4eb..fdcd8363d 100755 --- a/lint/check_experimental_boundaries.py +++ b/lint/check_experimental_boundaries.py @@ -7,14 +7,18 @@ 1. **Core never imports experimental.** ``unirl/`` must not reference the ``experimental`` namespace — the dependency arrow points one way. 2. **Experimental packages never import each other.** ``experimental/`` - may import ``unirl`` and its own package only; sharing code across - packages means it is ready to graduate into core, not ready to be - borrowed sideways. + may import ``unirl`` and its own package only — in any spelling: + absolute, ``from experimental import ``, bare ``import experimental``, + or a relative import that climbs past the package root. Top-level + ``experimental/*.py`` files are not a shared space and may not import + the tier at all. 3. **Recipe requirements are additive-only.** Reward and actor share one - Python process, so an ``experimental/**/requirements.txt`` cannot - version-"isolate" anything: any requirement whose (normalized) name is - already declared in ``pyproject.toml`` core dependencies would mutate - the locked core stack on install and is rejected. + Python process, so an ``experimental/**/requirements*.txt`` cannot + version-"isolate" anything: any requirement whose (normalized) name the + locked core stack already governs — ``[project.dependencies]``, every + ``[project.optional-dependencies]`` extra, or a ``[tool.uv]`` override + pin — would mutate that stack on install and is rejected, as are pip + option/include lines and URL requirements the name parser cannot vet. Run by the ``check-experimental-boundaries`` pre-commit hook. Exits non-zero listing each violation. @@ -43,22 +47,38 @@ def _py_files(base: Path): yield path -def _imported_roots(path: Path): +def _imports(path: Path): + """Yield ``(module, from_names)`` per import statement in ``path``. + + ``import a.b`` → ``("a.b", ())``; ``from a import b, c`` → ``("a", ("b", "c"))``. + Relative imports are resolved against the file's package (its directory + chain under ROOT) before yielding; a level that climbs out of the tree + yields nothing. + """ try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) except (OSError, SyntaxError): return + pkg = path.relative_to(ROOT).parts[:-1] for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - yield alias.name - elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: - yield node.module + yield alias.name, () + elif isinstance(node, ast.ImportFrom): + names = tuple(alias.name for alias in node.names) + if node.level == 0: + if node.module: + yield node.module, names + elif node.level - 1 <= len(pkg): + anchor = pkg[: len(pkg) - (node.level - 1)] + parts = (*anchor, *(node.module.split(".") if node.module else ())) + if parts: + yield ".".join(parts), names def check_core_does_not_import_experimental(errors: list[str]) -> None: for path in _py_files(ROOT / "unirl"): - for module in _imported_roots(path): + for module, _ in _imports(path): if module == "experimental" or module.startswith("experimental."): errors.append(f"{path.relative_to(ROOT)}: core imports {module!r} — the arrow points the other way") @@ -70,34 +90,70 @@ def check_no_cross_package_imports(errors: list[str]) -> None: for path in _py_files(exp): rel = path.relative_to(exp) own = rel.parts[0] if len(rel.parts) > 1 else None - for module in _imported_roots(path): - if not module.startswith("experimental."): - continue - target = module.split(".")[1] if "." in module else None - if target and own and target != own: + + def flag(module: str) -> None: + if own is None: + errors.append( + f"{path.relative_to(ROOT)}: imports {module!r} from top-level experimental/ — " + "the tier root is not a shared space; code lives inside one package" + ) + else: errors.append( f"{path.relative_to(ROOT)}: imports {module!r} from a sibling package — " "shared code graduates into core, it is not borrowed sideways" ) + for module, names in _imports(path): + if module == "experimental": + if not names: + errors.append( + f"{path.relative_to(ROOT)}: bare 'import experimental' reaches every sibling — " + "import your own package or core explicitly" + ) + for name in names: + if name != own: + flag(f"experimental.{name}") + elif module.startswith("experimental."): + if module.split(".")[1] != own: + flag(module) + + +def _core_dependency_names(pyproject: dict) -> set[str]: + """Every name whose version the locked core stack already governs.""" + deps = list(pyproject["project"]["dependencies"]) + for extra in pyproject["project"].get("optional-dependencies", {}).values(): + deps.extend(extra) + deps.extend(pyproject.get("tool", {}).get("uv", {}).get("override-dependencies", [])) + return {_normalize(_REQ_NAME_RE.match(dep).group(1)) for dep in deps} + def check_requirements_additive_only(errors: list[str]) -> None: - pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - core = {_normalize(_REQ_NAME_RE.match(dep).group(1)) for dep in pyproject["project"]["dependencies"]} + core = _core_dependency_names(tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))) exp = ROOT / "experimental" if not exp.is_dir(): return - for req_file in sorted(exp.rglob("requirements.txt")): + for req_file in sorted(exp.rglob("requirements*.txt")): if SKIP_PARTS.intersection(req_file.parts): continue - for line in req_file.read_text(encoding="utf-8").splitlines(): - line = line.split("#", 1)[0].strip() + rel = req_file.relative_to(ROOT) + for lineno, raw in enumerate(req_file.read_text(encoding="utf-8").splitlines(), 1): + line = raw.split("#", 1)[0].strip() if not line: continue - match = _REQ_NAME_RE.match(line) - if match and _normalize(match.group(1)) in core: + if line.startswith("-"): + errors.append( + f"{rel}:{lineno}: {line!r} — pip option/include lines are not allowed; " + "declare additive name-based pins only" + ) + continue + name_part = line.split(" @ ", 1)[0].strip() + match = _REQ_NAME_RE.match(name_part) + rest = name_part[match.end() :] if match else "" + if not match or (rest and rest[0] not in " [<>=!~;,"): + errors.append(f"{rel}:{lineno}: {line!r} — unparseable requirement; use PEP 508 name-based pins") + elif _normalize(match.group(1)) in core: errors.append( - f"{req_file.relative_to(ROOT)}: {line!r} re-declares a core dependency — " + f"{rel}:{lineno}: {line!r} re-declares a core dependency — " "requirements are additive-only (same-process colocation cannot version-isolate)" ) diff --git a/lint/check_recipe_targets.py b/lint/check_recipe_targets.py index ec1558656..61c50fda9 100755 --- a/lint/check_recipe_targets.py +++ b/lint/check_recipe_targets.py @@ -131,16 +131,35 @@ def main() -> int: top_level = {path: _top_level_names(source, path) for path, source in zip(needed, _read_all(needed))} failures: list[str] = [] + tier: list[str] = [] for path, lineno, dotted in targets: hit = module_split[dotted] names = top_level[hit[0]] if hit is not None else None if names is None or hit[1] not in names: failures.append(f"{path.relative_to(ROOT)}:{lineno}: unresolved _target_ '{dotted}'") + if dotted.startswith("experimental."): + # Tier direction: only experimental's own recipes may wire experimental code, + # and only within their own package (mirrors check_experimental_boundaries). + rel = path.relative_to(ROOT) + if rel.parts[0] != "experimental": + tier.append( + f"{rel}:{lineno}: core recipe targets '{dotted}' — the dependency arrow points the other way" + ) + elif len(rel.parts) > 2 and dotted.split(".")[1] != rel.parts[1]: + tier.append( + f"{rel}:{lineno}: recipe under 'experimental/{rel.parts[1]}/' targets sibling '{dotted}' — " + "shared code graduates into core" + ) if failures: print("Unresolved recipe _target_ paths (rename leftover or typo):", file=sys.stderr) for f in failures: print(f" {f}", file=sys.stderr) + if tier: + print("Experimental-tier direction violations:", file=sys.stderr) + for f in tier: + print(f" {f}", file=sys.stderr) + if failures or tier: return 1 print(f"check-recipe-targets: {len(targets)} recipe _target_ paths resolve.") return 0 From 1085b9304b68517956c7bfd9a21f79ac216cf54c Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 19:34:19 +0800 Subject: [PATCH 2/8] fix(bagel): eval-guard the trainside KV-context prefills like the replay path BagelPipeline._build_contexts runs the same vendored inference-signature prefills the #277 fix guarded in BagelDiffusionStage, but relied on callers already being in eval(). SFT/trainside can reach it mid-training; guard-and-restore instead of assuming the mode. --- unirl/models/bagel/pipeline.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/unirl/models/bagel/pipeline.py b/unirl/models/bagel/pipeline.py index 3444cdebb..5142545e2 100644 --- a/unirl/models/bagel/pipeline.py +++ b/unirl/models/bagel/pipeline.py @@ -288,12 +288,23 @@ def _build_contexts(self, prompt: str, image: Optional[Any] = None) -> Tuple[Any inf = self.bundle.inferencer gen = inf.init_gen_context() cfg_img = deepcopy(gen) - with torch.no_grad(), self._autocast_ctx(): - if image is not None: - gen = self._update_context_image(self._resize_input_image(image), gen, vae=True, vit=True) - cfg_text = deepcopy(gen) # snapshot before the prompt text → drop-text branch - gen = inf.update_context_text(prompt, gen) - cfg_img = inf.update_context_text(prompt, cfg_img) + # eval() is load-bearing (same contract as ``BagelDiffusionStage._build_contexts_from_prompt`` + # and ``rl_ops.forward_flow``): navit dispatches ``forward_train`` vs ``forward_inference`` + # on ``self.training`` and these prefills use the packed-query inference signature — in + # train() they TypeError. SFT/trainside callers can reach here mid-training, so guard + # and restore rather than assume the mode. + mot = self.bundle.transformer + was_training = mot.training + mot.eval() + try: + with torch.no_grad(), self._autocast_ctx(): + if image is not None: + gen = self._update_context_image(self._resize_input_image(image), gen, vae=True, vit=True) + cfg_text = deepcopy(gen) # snapshot before the prompt text → drop-text branch + gen = inf.update_context_text(prompt, gen) + cfg_img = inf.update_context_text(prompt, cfg_img) + finally: + mot.train(was_training) return gen, cfg_text, cfg_img def _t2i_cache_enabled(self) -> bool: From 05b0d0c93221c65f2ec193d0b99b6e807ef04edc Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 19:34:22 +0800 Subject: [PATCH 3/8] fix(refl): tolerate seed:null when seeding rollout latents DiffusionSamplingParams.seed is Optional and generate_latents supports None (unseeded path), but the call sites int()'d unconditionally. --- experimental/refl/models/wan21.py | 2 +- experimental/refl/models/wan22.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/experimental/refl/models/wan21.py b/experimental/refl/models/wan21.py index 508289c67..a20112e34 100644 --- a/experimental/refl/models/wan21.py +++ b/experimental/refl/models/wan21.py @@ -223,7 +223,7 @@ def diffuse_with_grad( latent_shape=latent_shape, device=device, dtype=self.trajectory_dtype, - base_seed=int(params.seed), + base_seed=None if params.seed is None else int(params.seed), ) # BPTT knobs. diff --git a/experimental/refl/models/wan22.py b/experimental/refl/models/wan22.py index 74d865d1a..8dbbfe839 100644 --- a/experimental/refl/models/wan22.py +++ b/experimental/refl/models/wan22.py @@ -191,7 +191,7 @@ def diffuse_with_grad( latent_shape=latent_shape, device=device, dtype=self.trajectory_dtype, - base_seed=int(params.seed), + base_seed=None if params.seed is None else int(params.seed), ) # BPTT knobs. From 56df09b54753e3ecaaef2085820b2bd8bc036a8a Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 19:34:26 +0800 Subject: [PATCH 4/8] chore(distributed): drop unreachable grad-input mismatch guard The dict-index comprehension raises KeyError before the length check can ever fire, and resolve() consumes the same keys earlier still. --- unirl/distributed/group/worker.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/unirl/distributed/group/worker.py b/unirl/distributed/group/worker.py index 1949d8aa6..c2ecf8dfd 100644 --- a/unirl/distributed/group/worker.py +++ b/unirl/distributed/group/worker.py @@ -312,11 +312,6 @@ def resolve(o): # Cross-RPC autograd can only propagate gradients back to controller-side # TensorRef inputs recorded by Handle as input_metas. tensors = [fetched[str(i)] for i in range(len(in_metas))] - if len(tensors) != len(in_metas): - raise RuntimeError( - f"Worker.call grad input mismatch for {method_name} call_id={call_id}: " - f"saved {len(tensors)} tensors for {len(in_metas)} TensorRef inputs" - ) for t in tensors: t.requires_grad_(True) t.retain_grad() From 070c8cdf4534ed376acc86e1fc72d027e74289ca Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 19:34:30 +0800 Subject: [PATCH 5/8] chore(gitignore): un-ignore the moved converter code; fix stale launch comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit datasets/* ignored the two converters #284 moved in (tracked but hidden from ruff/rg/editors; new siblings needed add -f) — whitelist the dirs, keep their jsonl outputs local-only. experimental/private_* now also covers stray private files. Two recipe comments pointed at scripts/*.sh paths that no longer exist (runner lives at examples/). --- .gitignore | 12 +++++++++--- examples/diffusion/bagel/bagel_trainside_lora.yaml | 2 +- examples/diffusion/qwen_image/qwen_image_sglang.yaml | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index cbd8ceeff..f0a2b1a96 100644 --- a/.gitignore +++ b/.gitignore @@ -72,10 +72,15 @@ local/ *.pkl *.log -# Datasets: keep the committed pickscore prompts, ignore other (local-only) datasets +# Datasets: keep committed prompt sets and converter code, ignore local-only data datasets/* !datasets/pickscore !datasets/geneval2 +!datasets/video_r1_260k +!datasets/daily_omni_av +# converter outputs stay local-only; only the converter code is tracked +datasets/video_r1_260k/*.jsonl +datasets/daily_omni_av/*.jsonl # Large model artifacts (defense-in-depth) models/**/*.bin @@ -105,5 +110,6 @@ benchmarks/text/aime/data/aime2025.jsonl benchmarks/text/gpqa/data/ benchmarks_results/ -# Private experimental packages stay local-only (the tier is also not packaged). -experimental/private_*/ +# Private experimental packages (dirs or stray files) stay local-only +# (the tier is also not packaged). +experimental/private_* diff --git a/examples/diffusion/bagel/bagel_trainside_lora.yaml b/examples/diffusion/bagel/bagel_trainside_lora.yaml index 61a72b3d8..6e21701dd 100644 --- a/examples/diffusion/bagel/bagel_trainside_lora.yaml +++ b/examples/diffusion/bagel/bagel_trainside_lora.yaml @@ -16,7 +16,7 @@ # the diffuse↔replay ratio==1 bit-exactness. This = the known-good v3 config on the # central-kitchen (reuse) Bagel stack. # -# Launch: bash scripts/migration/run_reuse_smoke.sh (offline smoke) or a long-run launcher +# Launch: bash examples/run_experiment_single_node.sh diffusion/bagel/bagel_trainside_lora # Compose: cd UniRL-main && PYTHONPATH=$PWD python -m unirl.train_diffusion \ # --config-name diffusion/bagel/bagel_trainside_lora --cfg job --resolve diff --git a/examples/diffusion/qwen_image/qwen_image_sglang.yaml b/examples/diffusion/qwen_image/qwen_image_sglang.yaml index 93a9be1d4..e4910b5a4 100644 --- a/examples/diffusion/qwen_image/qwen_image_sglang.yaml +++ b/examples/diffusion/qwen_image/qwen_image_sglang.yaml @@ -21,7 +21,7 @@ # (single-space negative — empty "" degenerates after the 34-token chat-template # strip and divides the norm-preserving CFG blend by ~0; see QwenImagePipeline.) # -# 1x8: bash scripts/run_experiment_single_node.sh diffusion_rl/qwen_image_sglang +# 1x8: bash examples/run_experiment_single_node.sh diffusion/qwen_image/qwen_image_sglang num_devices: 8 batch_size: 48 # prompts_per_rollout (matches qwen_image_dancegrpo) From 127afd131acff5668226bb2ccc9a49806ed391d3 Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 19:34:33 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(ci):=20label=20lifecycle=20=E2=80=94=20?= =?UTF-8?q?clean=20closed=20PRs,=20self-heal,=20unshared=20concurrency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - closed/merged PRs shed their status label (they used to keep 'need review' forever) - synchronize now applies 'need review' when a PR carries no status label at all, so one missed event no longer sticks forever - drop the shared concurrency group on the review-labels consumer: GitHub keeps one pending run per group, silently dropping the earlier PR's sync when reviews land on two PRs within seconds --- .github/workflows/pr-review-status-labels.yml | 6 ++--- .github/workflows/pr-status-labels.yml | 27 +++++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-review-status-labels.yml b/.github/workflows/pr-review-status-labels.yml index 4777ad21e..cb6619ab8 100644 --- a/.github/workflows/pr-review-status-labels.yml +++ b/.github/workflows/pr-review-status-labels.yml @@ -15,9 +15,9 @@ permissions: pull-requests: write issues: write -concurrency: - group: pr-review-status-labels - cancel-in-progress: false +# No concurrency group: GitHub keeps only one pending run per group, so a shared +# group silently drops the earlier PR's sync when reviews land on two PRs within +# seconds. Parallel runs are safe — each re-reads live PR state and converges. jobs: sync-labels: diff --git a/.github/workflows/pr-status-labels.yml b/.github/workflows/pr-status-labels.yml index 38ffe0c4a..3a424d16c 100644 --- a/.github/workflows/pr-status-labels.yml +++ b/.github/workflows/pr-status-labels.yml @@ -2,7 +2,7 @@ name: PR Status Labels on: pull_request_target: - types: [opened, reopened, ready_for_review, converted_to_draft, synchronize] + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, closed] permissions: # Labeling a PR goes through the issues API endpoint, but GitHub scopes the @@ -30,13 +30,30 @@ jobs: jq -e --arg name "$1" '.pull_request.labels | any(.name == $name)' "$event" >/dev/null } - [ "$(jq -r '.pull_request.state' "$event")" = "open" ] || exit 0 + if [ "$(jq -r '.pull_request.state' "$event")" != "open" ]; then + # Merged/closed PRs shed their status label so board views stay clean. + for label in "wip" "need review" "changes requested" "approved"; do + if has_label "$label"; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/issues/$number/labels/$(jq -rn --arg l "$label" '$l | @uri')" \ + >/dev/null || true + fi + done + exit 0 + fi if [ "$draft" = "true" ]; then target="wip" elif [ "$(jq -r '.action' "$event")" = "synchronize" ]; then - # New commits only move the status back to review after changes were requested. - has_label "changes requested" || exit 0 - target="need review" + # New commits move the status back to review after changes were requested, + # and self-heal a PR that carries no status label at all (missed event or + # transient write failure) — otherwise keep whatever label is there. + if has_label "changes requested"; then + target="need review" + elif ! has_label "wip" && ! has_label "need review" && ! has_label "approved"; then + target="need review" + else + exit 0 + fi else target="need review" fi From bca7e1e8c227db4704343569d4fc62c1a4c5d07c Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 21:09:22 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(review):=20address=20external=20review?= =?UTF-8?q?=20=E2=80=94=20same-PR=20label=20race,=20tier-root=20recipes,?= =?UTF-8?q?=20no-space=20direct=20refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pr-review-status-labels: per-PR concurrency group (run-id fallback for fork PRs whose workflow_run.pull_requests is empty) restores the same-PR serialize-latest semantics the removed global group provided, without the cross-PR pending-slot drops; DELETE tolerates the 404 a parallel fork-PR run can leave behind - check_recipe_targets: a recipe at the experimental/ root can no longer wire experimental.* — a recipe belongs inside one package - check_experimental_boundaries: PEP 508 direct references without spaces (name@git+https://...) parse instead of erroring; bare URL/VCS lines still fail closed --- .github/workflows/pr-review-status-labels.yml | 14 ++++++++++---- lint/check_experimental_boundaries.py | 8 ++++---- lint/check_recipe_targets.py | 7 ++++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr-review-status-labels.yml b/.github/workflows/pr-review-status-labels.yml index cb6619ab8..ab51543a5 100644 --- a/.github/workflows/pr-review-status-labels.yml +++ b/.github/workflows/pr-review-status-labels.yml @@ -15,9 +15,14 @@ permissions: pull-requests: write issues: write -# No concurrency group: GitHub keeps only one pending run per group, so a shared -# group silently drops the earlier PR's sync when reviews land on two PRs within -# seconds. Parallel runs are safe — each re-reads live PR state and converges. +# Per-PR concurrency: a shared group silently drops the earlier PR's sync when +# reviews land on two PRs within seconds (GitHub keeps one pending run per +# group), while no group at all lets same-PR runs race each other's label +# writes. workflow_run.pull_requests is empty for fork PRs — those fall back to +# a per-run group (no serialization; the tolerant DELETE below absorbs races). +concurrency: + group: pr-review-status-labels-${{ github.event.workflow_run.pull_requests[0].number || github.run_id }} + cancel-in-progress: false jobs: sync-labels: @@ -75,9 +80,10 @@ jobs: [ "$label" = "$target" ] && continue if jq -e --arg name "$label" '.labels | any(.name == $name)' <<<"$pr" >/dev/null; then encoded=$(jq -rn --arg label "$label" '$label | @uri') + # || true: a parallel fork-PR run may have removed it already (404 is benign). gh api --method DELETE \ "repos/$GITHUB_REPOSITORY/issues/$number/labels/$encoded" \ - >/dev/null + >/dev/null || true fi done diff --git a/lint/check_experimental_boundaries.py b/lint/check_experimental_boundaries.py index fdcd8363d..5f3f9242b 100755 --- a/lint/check_experimental_boundaries.py +++ b/lint/check_experimental_boundaries.py @@ -146,10 +146,10 @@ def check_requirements_additive_only(errors: list[str]) -> None: "declare additive name-based pins only" ) continue - name_part = line.split(" @ ", 1)[0].strip() - match = _REQ_NAME_RE.match(name_part) - rest = name_part[match.end() :] if match else "" - if not match or (rest and rest[0] not in " [<>=!~;,"): + match = _REQ_NAME_RE.match(line) + rest = line[match.end() :] if match else "" + # "@" covers PEP 508 direct references, spaced or not (name@git+https://...). + if not match or (rest and rest[0] not in " @[<>=!~;,"): errors.append(f"{rel}:{lineno}: {line!r} — unparseable requirement; use PEP 508 name-based pins") elif _normalize(match.group(1)) in core: errors.append( diff --git a/lint/check_recipe_targets.py b/lint/check_recipe_targets.py index 61c50fda9..2fb5e185a 100755 --- a/lint/check_recipe_targets.py +++ b/lint/check_recipe_targets.py @@ -145,7 +145,12 @@ def main() -> int: tier.append( f"{rel}:{lineno}: core recipe targets '{dotted}' — the dependency arrow points the other way" ) - elif len(rel.parts) > 2 and dotted.split(".")[1] != rel.parts[1]: + elif len(rel.parts) == 2: + tier.append( + f"{rel}:{lineno}: recipe at the experimental/ root targets '{dotted}' — " + "a recipe belongs inside one package (experimental//...)" + ) + elif dotted.split(".")[1] != rel.parts[1]: tier.append( f"{rel}:{lineno}: recipe under 'experimental/{rel.parts[1]}/' targets sibling '{dotted}' — " "shared code graduates into core" From c0951215ec0aef690ea4deac0971ed663cba7056 Mon Sep 17 00:00:00 2001 From: haonan3 Date: Fri, 31 Jul 2026 21:37:49 +0800 Subject: [PATCH 8/8] fix(review): second-round hardening from independent re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refl seed: replace the null-tolerance with a loud ValueError — roles.py documents fixed-noise as the verified DRaFT regime and no live config produces seed:null; silently unseeding violated the package's own contract and diverged from the mainline base classes - closed-PR label cleanup: delete all four labels unconditionally (the payload snapshot misses a label written by a review-sync racing the close instant, and nothing ever repairs a closed PR); tolerate only HTTP 404 on deletes here and in the review consumer — blanket || true would turn permission regressions (the one failure class observed in production) into green runs - review-labels concurrency: fork PRs fall back to a head-repo+branch group (actual serialization) instead of per-run; honest comments - boundaries checker: relative-import level bound now matches Python (level <= len(pkg)); SKIP_PARTS intersects repo-relative parts so a checkout living under a directory named vendor can't blank the rules; follower set gains tab (valid PEP 508 whitespace) - bagel guard comment documents why bundle.transformer suffices (vae/vit stay in load-time eval and are never mode-flipped) - gitignore: converter-output jsonl patterns cover nested dirs (**/) --- .github/workflows/pr-review-status-labels.yml | 26 ++++++++++++------- .github/workflows/pr-status-labels.yml | 14 +++++++--- .gitignore | 4 +-- experimental/refl/models/sd3.py | 7 ++++- experimental/refl/models/wan21.py | 7 ++++- experimental/refl/models/wan22.py | 7 ++++- lint/check_experimental_boundaries.py | 10 ++++--- unirl/models/bagel/pipeline.py | 4 ++- 8 files changed, 55 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pr-review-status-labels.yml b/.github/workflows/pr-review-status-labels.yml index ab51543a5..dd797f7e1 100644 --- a/.github/workflows/pr-review-status-labels.yml +++ b/.github/workflows/pr-review-status-labels.yml @@ -15,13 +15,15 @@ permissions: pull-requests: write issues: write -# Per-PR concurrency: a shared group silently drops the earlier PR's sync when -# reviews land on two PRs within seconds (GitHub keeps one pending run per -# group), while no group at all lets same-PR runs race each other's label -# writes. workflow_run.pull_requests is empty for fork PRs — those fall back to -# a per-run group (no serialization; the tolerant DELETE below absorbs races). +# Per-PR concurrency: a single shared group silently drops the earlier PR's sync +# when reviews land on two PRs within seconds (GitHub keeps one pending run per +# group), while no group at all lets same-PR runs race each other's label writes. +# Same-repo PRs serialize by PR number. Fork PRs (workflow_run.pull_requests is +# empty for them) serialize by head repo+branch — equivalent per PR in practice +# (two open PRs off one head branch would share a group). Manual dispatch sweeps +# share their own literal fallback group. concurrency: - group: pr-review-status-labels-${{ github.event.workflow_run.pull_requests[0].number || github.run_id }} + group: pr-review-status-labels-${{ github.event.workflow_run.pull_requests[0].number || format('{0}-{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }} cancel-in-progress: false jobs: @@ -80,10 +82,14 @@ jobs: [ "$label" = "$target" ] && continue if jq -e --arg name "$label" '.labels | any(.name == $name)' <<<"$pr" >/dev/null; then encoded=$(jq -rn --arg label "$label" '$label | @uri') - # || true: a parallel fork-PR run may have removed it already (404 is benign). - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/issues/$number/labels/$encoded" \ - >/dev/null || true + # A racing run may have removed the label already, so a 404 is benign — + # but ONLY a 404. Swallowing 403/5xx would turn permission regressions + # into green runs with wrong labels (the one failure ever observed in + # production was exactly a 403). + if ! out=$(gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/issues/$number/labels/$encoded" 2>&1 >/dev/null); then + grep -q "HTTP 404" <<<"$out" || { echo "$out" >&2; return 1; } + fi fi done diff --git a/.github/workflows/pr-status-labels.yml b/.github/workflows/pr-status-labels.yml index 3a424d16c..c6b6302fc 100644 --- a/.github/workflows/pr-status-labels.yml +++ b/.github/workflows/pr-status-labels.yml @@ -32,11 +32,15 @@ jobs: if [ "$(jq -r '.pull_request.state' "$event")" != "open" ]; then # Merged/closed PRs shed their status label so board views stay clean. + # Delete all four unconditionally: the payload's label snapshot can miss a + # label that a review-sync run racing the close instant wrote afterwards, + # and no later event ever repairs a closed PR (the dispatch sweep is + # open-only). A 404 (label absent) is benign; anything else fails loudly. for label in "wip" "need review" "changes requested" "approved"; do - if has_label "$label"; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/issues/$number/labels/$(jq -rn --arg l "$label" '$l | @uri')" \ - >/dev/null || true + encoded=$(jq -rn --arg l "$label" '$l | @uri') + if ! out=$(gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/issues/$number/labels/$encoded" 2>&1 >/dev/null); then + echo "$out" | grep -q "HTTP 404" || { echo "$out" >&2; exit 1; } fi done exit 0 @@ -47,6 +51,8 @@ jobs: # New commits move the status back to review after changes were requested, # and self-heal a PR that carries no status label at all (missed event or # transient write failure) — otherwise keep whatever label is there. + # Scope is deliberately zero-label-only: a stale-but-present label + # self-corrects on the next review event or dispatch sweep. if has_label "changes requested"; then target="need review" elif ! has_label "wip" && ! has_label "need review" && ! has_label "approved"; then diff --git a/.gitignore b/.gitignore index f0a2b1a96..baa3010da 100644 --- a/.gitignore +++ b/.gitignore @@ -79,8 +79,8 @@ datasets/* !datasets/video_r1_260k !datasets/daily_omni_av # converter outputs stay local-only; only the converter code is tracked -datasets/video_r1_260k/*.jsonl -datasets/daily_omni_av/*.jsonl +datasets/video_r1_260k/**/*.jsonl +datasets/daily_omni_av/**/*.jsonl # Large model artifacts (defense-in-depth) models/**/*.bin diff --git a/experimental/refl/models/sd3.py b/experimental/refl/models/sd3.py index 24c4f032c..2599096fb 100644 --- a/experimental/refl/models/sd3.py +++ b/experimental/refl/models/sd3.py @@ -99,12 +99,17 @@ def diffuse_with_grad( ) latents = initial_latents.to(device=device, dtype=self.trajectory_dtype) else: + if params.seed is None: + raise ValueError( + "REFL's fixed-noise regime needs an explicit sampling seed " + "(roles.py: params.seed is used verbatim every rollout/rank); set sampling.seed." + ) latents = self.generate_latents( batch_size=batch_size, latent_shape=latent_shape, device=device, dtype=self.trajectory_dtype, - base_seed=int(params.seed) if params.seed is not None else None, + base_seed=int(params.seed), ) sk: Dict[str, Any] = dict(params.sampler_kwargs or {}) diff --git a/experimental/refl/models/wan21.py b/experimental/refl/models/wan21.py index a20112e34..49cb13f00 100644 --- a/experimental/refl/models/wan21.py +++ b/experimental/refl/models/wan21.py @@ -218,12 +218,17 @@ def diffuse_with_grad( ) latents = initial_latents.to(device=device, dtype=self.trajectory_dtype) else: + if params.seed is None: + raise ValueError( + "REFL's fixed-noise regime needs an explicit sampling seed " + "(roles.py: params.seed is used verbatim every rollout/rank); set sampling.seed." + ) latents = self.generate_latents( batch_size=batch_size, latent_shape=latent_shape, device=device, dtype=self.trajectory_dtype, - base_seed=None if params.seed is None else int(params.seed), + base_seed=int(params.seed), ) # BPTT knobs. diff --git a/experimental/refl/models/wan22.py b/experimental/refl/models/wan22.py index 8dbbfe839..415527bbf 100644 --- a/experimental/refl/models/wan22.py +++ b/experimental/refl/models/wan22.py @@ -186,12 +186,17 @@ def diffuse_with_grad( ) latents = initial_latents.to(device=device, dtype=self.trajectory_dtype) else: + if params.seed is None: + raise ValueError( + "REFL's fixed-noise regime needs an explicit sampling seed " + "(roles.py: params.seed is used verbatim every rollout/rank); set sampling.seed." + ) latents = self.generate_latents( batch_size=batch_size, latent_shape=latent_shape, device=device, dtype=self.trajectory_dtype, - base_seed=None if params.seed is None else int(params.seed), + base_seed=int(params.seed), ) # BPTT knobs. diff --git a/lint/check_experimental_boundaries.py b/lint/check_experimental_boundaries.py index 5f3f9242b..a9a0369a8 100755 --- a/lint/check_experimental_boundaries.py +++ b/lint/check_experimental_boundaries.py @@ -43,7 +43,9 @@ def _normalize(name: str) -> str: def _py_files(base: Path): for path in sorted(base.rglob("*.py")): - if not SKIP_PARTS.intersection(path.parts): + # Intersect repo-relative parts: an absolute-path match would let a checkout + # that merely lives under a directory named "vendor" skip every rule. + if not SKIP_PARTS.intersection(path.relative_to(ROOT).parts): yield path @@ -69,7 +71,7 @@ def _imports(path: Path): if node.level == 0: if node.module: yield node.module, names - elif node.level - 1 <= len(pkg): + elif node.level <= len(pkg): anchor = pkg[: len(pkg) - (node.level - 1)] parts = (*anchor, *(node.module.split(".") if node.module else ())) if parts: @@ -133,7 +135,7 @@ def check_requirements_additive_only(errors: list[str]) -> None: if not exp.is_dir(): return for req_file in sorted(exp.rglob("requirements*.txt")): - if SKIP_PARTS.intersection(req_file.parts): + if SKIP_PARTS.intersection(req_file.relative_to(ROOT).parts): continue rel = req_file.relative_to(ROOT) for lineno, raw in enumerate(req_file.read_text(encoding="utf-8").splitlines(), 1): @@ -149,7 +151,7 @@ def check_requirements_additive_only(errors: list[str]) -> None: match = _REQ_NAME_RE.match(line) rest = line[match.end() :] if match else "" # "@" covers PEP 508 direct references, spaced or not (name@git+https://...). - if not match or (rest and rest[0] not in " @[<>=!~;,"): + if not match or (rest and rest[0] not in " \t@[<>=!~;,"): errors.append(f"{rel}:{lineno}: {line!r} — unparseable requirement; use PEP 508 name-based pins") elif _normalize(match.group(1)) in core: errors.append( diff --git a/unirl/models/bagel/pipeline.py b/unirl/models/bagel/pipeline.py index 5142545e2..524f2f0d5 100644 --- a/unirl/models/bagel/pipeline.py +++ b/unirl/models/bagel/pipeline.py @@ -292,7 +292,9 @@ def _build_contexts(self, prompt: str, image: Optional[Any] = None) -> Tuple[Any # and ``rl_ops.forward_flow``): navit dispatches ``forward_train`` vs ``forward_inference`` # on ``self.training`` and these prefills use the packed-query inference signature — in # train() they TypeError. SFT/trainside callers can reach here mid-training, so guard - # and restore rather than assume the mode. + # and restore rather than assume the mode. The transformer handle is sufficient: vae and + # vit_model stay in their load-time eval() (bundle.py) — training mode-flips only the + # ``transformer`` trainable module, and siglip has no signature dispatch anyway. mot = self.bundle.transformer was_training = mot.training mot.eval()