From d701b06a7b40e24addbb826e1a3f60f6c66cb9a6 Mon Sep 17 00:00:00 2001 From: haonan3 Date: Thu, 30 Jul 2026 22:49:34 +0800 Subject: [PATCH] feat(experimental): tier contract, boundary lint, private-package convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writes down the experimental-tier rules converged in the #210 review and makes the mechanical ones lint-enforced: - experimental/README.md — the contract: two-way flow with core (official flows live in core; mainstream packages graduate up, ill-fitting core paths move down), package anatomy with mirror naming (models/ reward/ examples/ named after the core home content graduates into), locked single-stack policy, additive-only requirements with the differentiable⇒same-process⇒same-stack corollary, owner+verification table requirement. - scripts/check_experimental_boundaries.py (pre-commit): core never imports experimental; packages never import each other; recipe requirements may not re-declare core dependencies (same-process colocation cannot version-isolate). - experimental/private_*/ gitignored; the tier is not packaged into the wheel, so private code cannot leak into a build. --- .gitignore | 3 + .pre-commit-config.yaml | 8 ++ experimental/README.md | 70 +++++++++++++ scripts/check_experimental_boundaries.py | 120 +++++++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 experimental/README.md create mode 100755 scripts/check_experimental_boundaries.py diff --git a/.gitignore b/.gitignore index 2745d15d0..cbd8ceeff 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,6 @@ benchmarks/image/dpg_bench/data/ 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_*/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef24c1faf..efefad999 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,3 +51,11 @@ repos: language: python pass_filenames: false always_run: true + # Experimental-tier boundaries: core never imports experimental, + # packages never import each other, requirements stay additive-only. + - id: check-experimental-boundaries + name: experimental-tier boundaries hold + entry: python scripts/check_experimental_boundaries.py + language: python + pass_filenames: false + always_run: true diff --git a/experimental/README.md b/experimental/README.md new file mode 100644 index 000000000..99e133821 --- /dev/null +++ b/experimental/README.md @@ -0,0 +1,70 @@ +# experimental/ — the incubation tier + +The **official** training flows live in core (`unirl/train_*.py` + the +trainers). This tier is the other half of a deliberate two-way flow: + +- **up** — packages incubate here and, once mainstream and verified, get + absorbed and solidified into core; +- **down** — core paths that outgrow or never fit the official + abstractions move here (or out); +- **private** — internal packages that cannot be open-sourced live here + *uncommitted* (`experimental/private_*/`, gitignored; this tier is not + shipped in the wheel, so nothing private can leak into a build). + +Nothing under `experimental/` carries core guarantees: no API stability, +review runs at package-owner discretion, and core CI only lint-gates it. + +## What a package looks like + +``` +experimental// + __init__.py run.py # python -m experimental..run --config-name= + trainer.py # Trainer(BaseTrainer) — the loop + roles.py # package-local Remote workers (when needed) + models/ # mirrors unirl/models/ — graduates into the matching model packages + reward/ # mirrors unirl/reward/ — graduates into unirl/reward/local/ + examples/ # mirrors top-level examples/ — graduates into examples// + README.md # launch one-liners + owner + the verification table +``` + +**Mirror naming (促成"能上能下")**: a directory is named after the core +home its content graduates into, so promotion is a structural no-op. No +`scripts/` directories — the launch surface is one documented command in +the README. No `common/` shared space — code needed by a second package +graduates into core instead of being borrowed sideways. + +## Rules (lint-enforced where possible) + +1. **Import direction** (`scripts/check_experimental_boundaries.py`): + core never imports `experimental`; packages never import each other. +2. **Additive-only requirements** (same script): reward and actor share + one Python process, so a `requirements.txt` cannot version-"isolate" — + it may only ADD packages absent from the core stack. Corollary: + differentiable rewards must run on the locked core stack; a reward + that genuinely needs a conflicting stack must be non-differentiable + and go out-of-process (`unirl-reward-service`). +3. **Single locked stack**: code targets the versions pinned by + `pyproject.toml` only — no version-compat branches; a wrong + environment fails loudly and the user aligns the environment. +4. **`_target_` hygiene**: every dotpath in `experimental/**` configs + must resolve (`scripts/check_recipe_targets.py` scans this tier). +5. **Owner + verification**: each package README carries its owner and a + verification table (config × hardware × head × status). Unverified + drive-by configs are rejected in review. +6. **Model/engine variation goes through `_target_` polymorphism** — + an `if model_family == ...` branch in package code is a review reject. + +## Graduation + +Both directions are deliberate PRs, never drive-bys. Up: a second +consumer outside the package (or the team adopting it as recommended +practice) triggers promotion into the mirrored core home — including +deduplication against any core sibling implementation. Down: core paths +bypassed by the official abstractions are candidates to move here. + +## Launch & environment + +Packages run from a repo checkout (`python -m experimental..run`); +this tier is intentionally **not packaged** into the wheel. Environments +come from the locked stack (image/lockfile) — see each package README's +environment section. diff --git a/scripts/check_experimental_boundaries.py b/scripts/check_experimental_boundaries.py new file mode 100755 index 000000000..58a61b4eb --- /dev/null +++ b/scripts/check_experimental_boundaries.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Static guard for the experimental-tier boundaries. + +Three rules, all enforceable without importing anything (pure ``ast`` + +text, same spirit as ``check_recipe_targets.py``): + +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. +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. + +Run by the ``check-experimental-boundaries`` pre-commit hook. Exits +non-zero listing each violation. +""" + +from __future__ import annotations + +import ast +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKIP_PARTS = {".git", "vendor", "__pycache__"} + +_REQ_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") + + +def _normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _py_files(base: Path): + for path in sorted(base.rglob("*.py")): + if not SKIP_PARTS.intersection(path.parts): + yield path + + +def _imported_roots(path: Path): + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (OSError, SyntaxError): + return + 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 + + +def check_core_does_not_import_experimental(errors: list[str]) -> None: + for path in _py_files(ROOT / "unirl"): + for module in _imported_roots(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") + + +def check_no_cross_package_imports(errors: list[str]) -> None: + exp = ROOT / "experimental" + if not exp.is_dir(): + return + 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: + errors.append( + f"{path.relative_to(ROOT)}: imports {module!r} from a sibling package — " + "shared code graduates into core, it is not borrowed sideways" + ) + + +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"]} + exp = ROOT / "experimental" + if not exp.is_dir(): + return + 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() + if not line: + continue + match = _REQ_NAME_RE.match(line) + if match and _normalize(match.group(1)) in core: + errors.append( + f"{req_file.relative_to(ROOT)}: {line!r} re-declares a core dependency — " + "requirements are additive-only (same-process colocation cannot version-isolate)" + ) + + +def main() -> int: + errors: list[str] = [] + check_core_does_not_import_experimental(errors) + check_no_cross_package_imports(errors) + check_requirements_additive_only(errors) + if errors: + print("check-experimental-boundaries: FAILED") + for err in errors: + print(f" {err}") + return 1 + print("check-experimental-boundaries: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())