Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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_*/
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions experimental/README.md
Original file line number Diff line number Diff line change
@@ -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/<name>/
__init__.py run.py # python -m experimental.<name>.run --config-name=<cfg>
trainer.py # <Name>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/<name>/
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.<name>.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.
120 changes: 120 additions & 0 deletions scripts/check_experimental_boundaries.py
Original file line number Diff line number Diff line change
@@ -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/<a>``
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())
Loading