Skip to content

fix(genrm): integrate unified inference and elastic scaling - #428

Closed
Arazyu wants to merge 52 commits into
redai-studio:mainfrom
Arazyu:codex/integrate-genrm-inference
Closed

Arazyu wants to merge 52 commits into
redai-studio:mainfrom
Arazyu:codex/integrate-genrm-inference

Conversation

@Arazyu

@Arazyu Arazyu commented Oct 11, 2026

Copy link
Copy Markdown

What

Integrate the unified inference service from #356 with GenRM elastic scaling from #370. GenRM scaling now shares the inference gateway's routing and discovery rules, and gateway requests participate in the scale-in drain accounting.

This is a draft integration candidate against main containing both implementations and their original commit histories:

The diff therefore includes both source PRs, not just conflict-resolution edits. The original PRs remain open for their respective reviews. The integration changes are in the merge commit; the two parent revisions identify the source implementations.

Why

Relates to #71 and #351; integrates #356 and #370.

Both implementations modify the GenRM component, manager and shared engine lifecycle. A mechanical merge could expose unpublished or draining engines through canonical discovery, bypass legacy scoring behavior and in-flight accounting through the gateway, or lose placement-group ownership during cleanup. These paths need consistent behavior before the implementations can run together.

How

  • Preserve legacy message generation, sampling defaults and response attribution through the shared gateway. Count raw and streaming requests until completion, and bind retired-endpoint admission to engine identity so a recovered engine can reuse an address.
  • Use the same published engine states for canonical discovery and legacy routing. Keep candidates, draining engines and retired slots out of routing; exclude retired slots from recovery.
  • Preserve split-layout bundle offsets and owned elastic placements. Keep PG ownership until Ray confirms REMOVED, retain unfinished cleanup for reconciliation, and allocate a fresh PG when recovering a failed elastic engine. Preserve shared Teacher PG ownership until the last worker is released.
  • Reject deferred sharing, training-offload pools (--offload-rollout) and multi-GPU elastic engines before acquiring the training lifecycle lock or allocating resources. Dynamic state and cleanup checks remain inside lifecycle serialization.
  • Update the English/Chinese GenRM and Rollout API documentation and regenerate the GenRM OpenAPI schema.

Elastic scaling retains #370's scope: single-GPU elastic engines and drain accounting within one Gateway. Consistent direct-client routing does not establish direct-client or cross-Gateway graceful drain. This integration does not add live elastic scaling for pools that participate in training offload.

Testing

Local environment: macOS arm64, Python 3.12.13, Ray 2.59.0, pytest 9.1.1, CPU only; Python commands ran through uv in a temporary environment. No GPU allocation was used for this integration.

Validation Result
Unified inference, engine/Teacher lifecycle, GenRM scaling, watcher, registry and Autoscaler regressions 426 passed, 4 skipped
Final affected-path rerun after the lifecycle lock-order refinement 34 passed; overlaps the preceding suite
Additional demo/API/backend contract suite 12 passed, 17 skipped
pre-commit run --all-files --show-diff-on-failure All hooks passed
VitePress documentation build Passed; existing missing source-link warnings remain
Merge against main at f268f427a31844f2d0d54ad7374acfa6edce23e9 Clean in git merge-tree

The four main-suite skips concern an unavailable Rollout import. The additional skips concern unavailable SGLang, Rollout import dependencies or the TransferQueue sampler API. The full repository test suite was not run locally. Real SGLang execution and multi-node GPU training have not been revalidated on this integrated head; inherited evidence in the source PRs and demos/task4_genrm/ describes its original producing revisions and is not a new integration result. Remote CI results must be assessed separately.

Test and build commands

Run the Python commands with the project's test dependencies installed in a uv environment:

uv run --no-project --python "$TEST_PYTHON" python -m pytest -q \
  tests/inference \
  tests/distributed/ray/test_multi_engine_manager.py \
  tests/distributed/ray/test_genrm_scale_placement.py \
  tests/distributed/ray/test_teacher_manager.py \
  tests/components/test_genrm_scale_endpoints.py \
  tests/components/test_genrm_scale_watcher.py \
  tests/utils/test_genrm_scale_registry.py \
  tests/utils/autoscaler \
  tests/backends/megatron/test_model_provider_vpp.py \
  --disable-warnings --tb=short -ra

uv run --no-project --python "$TEST_PYTHON" python -m pytest -q \
  tests/distributed/ray/test_genrm_scale_placement.py \
  tests/distributed/ray/test_teacher_manager.py \
  tests/components/test_genrm_scale_endpoints.py::TestUnifiedGatewayIntegration \
  --disable-warnings --tb=short -ra

uv run --no-project --python "$TEST_PYTHON" python -m pytest -q \
  tests/backends/sglang/test_sglang_engine.py \
  tests/components/test_scale_response_models.py \
  tests/utils/data/test_identity_window_sampler.py \
  demos/task4_genrm/test_contract_demo.py \
  --disable-warnings --tb=short -ra

uv run --no-project --python "$TEST_PYTHON" pre-commit run --all-files --show-diff-on-failure
node_modules/.bin/vitepress build docs
node docs/fix-chunk-names.js

TEST_PYTHON is the path to the test environment's Python executable.

  • pre-commit run --all-files passes
  • Tests pass (pytest tests/)
  • New tests added (if applicable)
  • Documentation updated (if applicable)

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • CI/CD or build changes

Screenshots / Logs

426 passed, 4 skipped, 123 warnings, 3 subtests passed
34 passed, 17 warnings, 5 subtests passed
12 passed, 17 skipped, 17 warnings
All pre-commit hooks: Passed
VitePress: build complete

iArazy and others added 30 commits September 22, 2026 22:48
# ⭐ Feature

## Share the inference control plane

- Add the CPU-only gateway, common manager, lifecycle coordinator, placement planner, routing schema, and compatibility client for Rollout, GenRM, and Teacher.
- Validate split, defer, decoupled placement and reject same-phase co-residency before startup.
- Coordinate drain, offload, onload, deferred Teacher publication, and dynamic Rollout updates without changing role workloads.

---

# ✅ Tests

## Cover RFC redai-studio#71 contracts and GPU workflows

- Add CPU tests for routing, discovery, placement, lifecycle rollback, PG ownership, static-weight protection, legacy clients, and deferred publication.
- Add opt-in PP/PD topology and three-role GPU integration tests and validation launchers.
- Record passing split and decoupled multi-node training evidence in the bilingual validation guide.

---

# 📝 Documentation

## Document the acceptance evidence

- Add English and Chinese API and validation guides with reproducible commands and acceptance mapping.
- Keep the scope limited to the explicit RFC redai-studio#71 acceptance checks.
# 🐛 Bug Fix

## Keep weight readiness test doubles compatible

- Add the rollout readiness RPC to the handshake manager stub.
- Preserve failed synchronization behavior while allowing successful resume.

---

# 🎨 Style

## Apply required CI formatting

- Apply the repository's ruff and docformatter output to touched Python files.
# 🐛 Bug Fix

## Preserve legacy engine discovery and startup fixtures

- Keep `get_engines_info()` compatible with managers created without the unified inference manager.
- Update the startup cleanup fixture for the fully async weight-update arguments and metrics state.

---

# 📝 Documentation

## Keep the public inference API focused

- Remove internal acceptance reports and experiment details from the public API docs.
- Move shared inference links into the related lifecycle and placement sections.

---

# 🔩 Chore

## Remove private validation wrappers

- Delete one-off cluster scripts containing site-specific paths and runtime settings.
关联 redai-studio#351。官方验收标准五项全部达成(证据矩阵见 PR 描述)。
单 commit 汇总全部开发提交;完整历史在
feat/genrm-elastic-scaling-history 分支(证据文件中的 hash 引用指向该分支)。

扩缩 API 与状态机(relax/utils/genrm_scale_registry.py,纯标准库):
- POST /genrm/scale_out|scale_in,num_replicas 为目标绝对总数
- 同键幂等重放(verbatim NOOP replay);按模型互斥,未决清理同样阻塞
- 逐操作状态查询与 reconcile 端点

Manager 生命周期(relax/distributed/ray/genrm.py、multi_engine_manager.py):
- 每副本独占 placement group,创建即登记 ownership,所有失败出口
  (就绪超时、InfoActor 创建/探测/kill、abort)落到确认 REMOVED 或
  pending cleanup,扩缩互斥保持到 reconcile 确认
- 健康检查通过后才发布路由;超时后迟到的结果不再发布
- 缩容最新优先选 victim,初始引擎受保护;drain 完成才销毁
- PG 释放以 Ray 确认 REMOVED 为准(remove_placement_group() 返回不算)
- 物理完成栅栏:终态仅在 lifecycle 线程停止后计入
- multi_engine_manager +55 行:REMOVED 确认轮询与 pending 清理重试,
  落在 GenRM/Teacher 共享骨架(Rollout 侧零改动,Task 3 融合点)

Autoscaler 服务隔离(relax/utils/autoscaler/):
- per-service runtime:独立采集器/决策引擎/策略/历史
- ServiceScalingPolicy 支持 GenRM 独立阈值,rollout 配置向后兼容
- /conditions、/scale_history、/metrics_history 支持 service= 过滤
- 指标逐字段有效性校验;unknown_engines 冻结 scale-in(健康路径与
  旧实现一致,异常路径更保守,见 PR 描述"需维护者确认的共享语义变更")

可观测性:
- /metrics 上报 Manager 实时容量
- TUI monitor --service genrm 视图与无头 SVG 截图

测试与 GPU 证据(demos/task4_genrm/EVIDENCE.md,全部 hash pin 可复算):
- 96 个任务相关 CPU 测试(含 PG ownership 故障注入,修复前验证为红)
- 手动 1→2→1:4,163 请求 0 失败,贪心前缀跨阶段一致
- Autoscaler 全周期:3,202 请求 0 失败;冻结阈值轮 r2 12/14(A7/B3
  定性为验收口径缺陷:Ray 2.58 保留 REMOVED 墓碑)→ r3 以 v2 预注册
  口径(非终态 PG 计数,阈值逐字冻结)14/14 全绿
- 奖励一致性:greedy 跨引擎逐输入一致;官方采样 4% 不稳定率定性为
  逐引擎种子配置属性
- 故障注入:607.3s fail-closed 停等后 reconcile 清理确认;SIGKILL
  victim 后在飞请求全部重试到初始引擎
- 最小训练回路(dapo-genrm):step 训练、权重同步 200 OK ×9、judge
  调用真实发生(judge_response 落盘)
- 训练连续性(官方验收第 5 项):真实配方一次运行内 scale_out→ACTIVE
  (55s)→scale_in→COMPLETED(1s 排空),双窗口内训练事件无 >120s
  停滞、弹性引擎真实服务奖励、8/8 rollouts 跑完、零错误
- B2 冒烟与连续性配方 scripts/training/genrm/run-qwen3-0.6B-4xgpu-*.sh
…le closed

Two review findings on the scale-in drain contract:

1. Reconcile no longer falls through when the manager progress query
   fails. Previously a timeout/error returned {} and reconcile proceeded
   with victim=None, so the manager retired its recorded victim on the
   caller's (missing) drain proof alone, terminating requests still in
   flight inside the single Gateway. Reconcile now fails closed with 503
   until the victim is positively identified and proven at zero
   in-flight (regression tests: progress-query failure and unknown
   victim both refuse; identified drained victim still reconciles).

2. Drain confirmation is bound to the victim the watcher observed. A
   multi-victim scale-in reuses the request id and swaps the drain event
   per victim, so a delayed duplicate confirm for the previous victim
   could release the next one without its in-flight count ever reaching
   zero. confirm_scale_drained now takes the observed victim address and
   rank and ignores stale identities under the scale lock; the watcher
   passes them (regression tests: stale confirm between victims and
   after retirement both ignored; legacy no-arg confirm still works).
…tors

Two review findings on resource and control-plane safety:

1. _remove_owned_pg previously used membership in _pending_pg_cleanup to
   skip resubmitting remove_placement_group(). When the first submission
   itself raised (before Ray accepted the deletion), the rank entered
   _pending_pg_cleanup, every later retry only polled the table, and the
   PG stayed CREATED forever -- GPU leak plus a permanently held model
   scale mutex. Deletion-submitted state is now tracked separately
   (_pg_remove_submitted): a failed submission is retried, an accepted
   one is only polled for REMOVED confirmation (regression tests: first
   submit failure resubmits then confirms REMOVED; accepted submission
   is never resubmitted).

2. AutoscalerService._rebuild_service_runtimes constructed a fresh
   MetricsCollector for a service target added via PATCH /config but
   never started it -- without an HTTP session the new target collected
   nothing and never autoscaled; a removed target kept its session
   alive. The rebuild now records collectors to start/stop and the
   async PATCH path starts/stops them before publishing the new runtime
   set; start() consumes the pending list (regression tests: PATCH adds
   a started collector, PATCH removing the target stops it).
…apshots, bounded history

Review findings on the scale control plane:

- num_replicas is now a strict int: JSON true / "2" / 2.0 are rejected
  422 at the HTTP boundary instead of being coerced by pydantic before
  the registry's isinstance checks (boundary test covers all three).
- Scale submission fails closed when the authoritative capacity cannot
  be queried: the mutating path uses strict capacity lookups and returns
  503 instead of submitting against a degraded snapshot where ready
  impersonates current; read-only /engines keeps its degraded view.
- A watcher crash no longer zero-fills capacity: the terminal snapshot
  falls back to the registry's last known values (previous terminal
  snapshot, else the submit-time observation, now exposed in status)
  instead of inventing current=0/ready=0, and an entirely empty
  progress payload keeps cleanup_required set (fail closed) while a
  non-empty legacy snapshot without the key keeps its old meaning.
- The registry is a long-running control plane: clean terminal
  operations are bounded to max_history (oldest evicted, counted in
  history_truncated), idempotency records whose clean outcome aged out
  of the replay window expire to fresh-request semantics, and dirty
  terminals / live operations are never evicted.
Review finding: the GenRM server args derived random_seed from the
engine rank, so the elastic replica sampled differently from the initial
one and the two replicas disagreed on borderline inputs under the
official sampling config (measured 2/50 verdict flips in the reward
consistency run). GenRM is a frozen scoring service: every replica must
sample identically for an identical request, so the seed is now the base
args.seed on every rank. The rollout builder keeps its rank-derived seed
(sampling diversity is wanted there, not here).
Review findings on the autoscaler control plane:

- Per-service evaluation now runs concurrently (asyncio.gather with
  per-service exception isolation): a stalled GenRM metrics endpoint can
  no longer delay the rollout service's evaluation cadence (regression
  test parks the GenRM evaluation and asserts rollout completes).
- A legacy PATCH of rollout_service_url is no longer shadowed by the
  startup service_targets entry: the two spellings are kept in sync in
  both directions, so the patched URL actually takes effect (tests for
  both directions).
- /status reports per-service last_scale_action / last_scale_time and
  the monitor's service view overlays them, so --service genrm shows
  GenRM's own scale history instead of the rollout runtime's.
- Metrics aggregation is per-field: an engine whose scrape is missing
  only throughput still contributes its observed token_usage and queue
  depth to scale-out signals, while remaining an unknown engine for the
  conservative scale-in gate (cross-layer regression test).
- Together with the collector hot start/stop from the previous commit,
  PATCHed service targets are fully live at runtime.
Review finding: the continuity verdict's single in-window check used the
scale-out COMPLETION time as its lower bound, so the whole scale-out
process was excluded and one train event between the two operations
satisfied the assertion even when the 1 s scale-in window contained
nothing. The verdict now asserts progress separately before the
operations, between them, and after scale-in -- the honest, observable
windows -- and the run-6 evidence is re-checked against them by the
final-head rerun (the 1 s drain window is shorter than one training
iteration; progress during it is not an observable claim).
- registry: strictly bounded idempotency and history (fixed-window TTL +
  max_idempotency_records hard cap; NOOP and dangling records evictable;
  live/dirty-terminal protected with a provable per-model ceiling)
- autoscaler: PATCH /config refuses (409) to remove a service target that
  still owns non-terminal or cleanup_required scale operations
- manager: confirm_scale_drained requires the victim identity (no legacy
  unbound form); mismatched identity never releases the drain fence
- watcher: a crash merges the freshest observed progress counts into the
  FAILED finish instead of falling back to submit-time capacity
- docs: autoscaler cadence wording narrowed to per-cycle concurrency
Raw artifacts (event timelines, scale histories, charts, TUI screenshots,
superseded rounds, per-request logs) move to the evidence branch (complete
tree backfilled and pinned at c56c821); the PR keeps the final machine
verdicts and the frozen preregistration documents only. Engine host fields
in the retained summaries are normalized to node-0, matching the documented
convention (raw values stay on the evidence branch).
…iant seeding

Engine A serves N stochastic judge requests alone, elastic engine B boots
afterwards with the same server seed, then a fixed probe set is attributed
per engine: per-input verdict sets must agree despite diverged RNG states.
Ready to run on the acceptance GPUs; flips, if any, are recorded in full
for the request-level sampling_seed follow-up protocol.
…le-ins

A scale-in that physically completed underneath a watcher which lost its
progress RPCs until deadline left the registry FAILED + cleanup_required
with the manager reporting COMPLETED / physical_done / no victim; the
reconcile then refused forever. Victim-less snapshots now release only on
that positive completion proof (COMPLETED + physical_done); FAILED and
live-lifecycle snapshots keep the fail-closed 503.
The single-cycle gather barrier let one stalled service (30 s HTTP waits in
GenRM discovery / pending-request polls) starve every later cycle for all
services. Each service now owns an evaluation task with its own interval
timing; the supervisor reconciles the worker set across config PATCHes.
Registry stress gains the bot's TTL-advance dangling-key repro.
…a PATCH

A worker keyed only by service name kept evaluating the pre-PATCH
ServiceRuntime (stale state/collector) forever. The supervisor now keys on
runtime object identity. Also: the divergence driver's load_rows skips on
usable rows (the capped-list slice returned fewer rows than requested), and
EVIDENCE.md pins the correct full evidence-branch SHA.
Adversarial finding (e2e_sampling_divergence, Qwen3-0.6B, temp 0.1): with
server-level args.seed seeding alone, two replicas whose request histories
diverged by 300 stochastic requests flipped verdicts on 2/50 fixed inputs --
each engine's RNG stream had advanced differently, so the same scoring
request drew different verdicts depending on which replica served it.

Product semantics change: a stochastic scoring request (temperature > 0)
now derives an int32 sampling seed from SHA256(judge model path, exact
model-facing input_ids, effective sampling config) and passes it to SGLang's
per-request deterministic sampler. Identical scoring requests produce
identical verdicts on every replica, independent of RNG-consumption
history. Greedy requests are unchanged (already deterministic); an explicit
caller-provided sampling_seed is respected.
… effect

The request-level sampling_seed contract (1dbb9e3) was silently inert:
SGLang drops the per-request seed tensor unless the server runs with
enable_deterministic_inference, so sampling still drew from each engine's
global RNG stream -- which advances with every request served. The
adversarial rerun (sampling_divergence_20260925_r2) still flipped 2/50
under a replica-invariant server seed, exactly matching the
RNG-history-divergence mechanism.

GenRM engines now boot with enable_deterministic_inference=True: the
per-request seed drives SGLang's deterministic sampler (pytorch sampling
backend, radix cache disabled; tp_size=1 keeps NCCL unconstrained).
…1/B2/continuity reruns

Adversarial divergence r3 (deterministic sampling activated): 0/50 flips
across 300-request diverged RNG histories. B1 r3: official sampling
instability 0/50, judge 97%/97%. B2 r3: training + weight sync x9 +
graceful shutdown on the seed-contract head. Continuity r5: 12/12
three-window assertions, 8/8 rollouts.
shanyulu and others added 22 commits September 26, 2026 00:20
…rs over gracefully

Re-review findings: (1) between a scale POST leaving and its acceptance
response landing, the remove-target guard saw an empty pending list, so a
PATCH could delete the target and strand an operation the server had
accepted; _execute_scale_out/in now register a SUBMITTING placeholder
before the POST (non-terminal -> blocks removal; NOOP/CONFLICT/rejection
remove it again). (2) cancelling a rebuilt worker mid-POST lost that
response; workers now hand over gracefully -- the old worker finishes its
current evaluation (in-flight POSTs included, updating the placeholder in
the state object shared across the rebuild) and exits at the next iteration
boundary, re-reading the services mapping each round. Also: stochastic
requests carrying min_p are rejected with 422 (the deterministic sampling
path required for replica-invariant verdicts does not implement min-p on
the pinned SGLang), and HTTPException is no longer retried by the engine
retry loop.
Four verdict pins (divergence r3, reward r3, B2 r3, continuity r5) were
stale: they were taken before the node-0 normalization pass renamed host
fields in the retained summaries, so a verifier following the manifest
computed different hashes than the table listed. All 14 entries are now
recomputed from the HEAD blobs; the byte-identical r4/r5 continuity
verdicts (same assertion matrix after normalization) get an explanatory
note pointing at the distinguishing event timelines on the evidence branch.
… null temperature

Re-review residuals: (1) when a PATCH repointed a service's URL while an
operation's acceptance response was still in flight, the saved request_id
was polled at the NEW address (URL built from current config) and 404'd --
scale submissions now record the service_url they were sent to and
status/reconcile queries follow it (legacy entries fall back to the current
config); the handover test's pause moved inside the real POST response
window (same-address and changed-address variants covered). (2) a null
temperature slipped through float(value or 0.0) as greedy, but the pinned
SGLang normalizes null to 1.0 (stochastic) -- the request reached the
engine and hit the unsupported seeded min-p path; temperature must now be
an explicit number (422 otherwise), asserted to fire before any engine
HTTP call. The redundant except-HTTPException branch in the retry loop is
removed (validation precedes the loop).
Ambiguous POST ownership: a transport failure after server acceptance used
to delete the SUBMITTING placeholder, orphaning an operation the registry
had admitted (future 409s with no known request_id to reconcile).
Submissions now carry an idempotency_key, retries are bounded and
same-key, unrecoverable outcomes persist as SUBMIT_UNKNOWN (non-terminal:
keeps blocking target removal) and _update_pending_requests recovers
ownership via idempotent replay -- exactly one server-side operation per
key, proven by the accepted-but-response-lost and true-connect-failure
regressions.

PATCH /config atomicity: field mutations applied before the removal guard
could raise 409 left the config partially patched. The patch is now staged
on a deep-copied candidate and committed only after every validation
passes; a rejected PATCH leaves config, targets, runtimes, collectors,
workers and pending operations byte-identical (regression asserts full
non-mutation).

Sampling-seed semantics documented explicitly: content-deterministic
stochastic scoring (identical content -> identical verdicts on any replica
and across repeated calls), with the alternative per-request-diversity
contract noted as a deliberate future change.
Two P2 findings from the round-8 review of the ambiguous-submission
hardening:

P2-1 -- terminal replay is not cleanup proof. A same-key replay could
return a terminal status (FAILED/PARTIAL) whose POST body carries no
cleanup_required; the pending-queue check treated the missing flag as
False and moved the operation to history while the server-side registry
still held the per-model mutex (later scales 409 forever, reconcile
ownership lost). Cleanup is now three-state (True / False / UNKNOWN):
adoption from any POST reply (fresh, inline retry or background
SUBMIT_UNKNOWN recovery) resets it to UNKNOWN, and only the
authoritative status endpoint -- directly or after a reconcile -- may
prove it clean. Missing or malformed flags stay UNKNOWN (unknown !=
clean, fail closed), and the decision gate plus the remove-target guard
block on UNKNOWN exactly like on dirty.

P2-2 -- recovered operations kept no cooldown accounting. Background
recovery only wrote request_id/status, so last_scale_time/action stayed
None (or stale) and the next evaluation could immediately scale again.
All adoption paths now share _adopt_scale_operation, which records
last_scale_action and last_scale_time at adoption time (conservative for
recovery: the server's acceptance timestamp is unknown; a slightly late
cooldown beats an invisible operation).

Unified lifecycle: _dispatch_scale_post (submit / ambiguous outcome) ->
_adopt_scale_operation (ownership + cooldown + cleanup UNKNOWN) ->
_refresh_authoritative_status (status endpoint is the only cleanup
authority) -> _maybe_reconcile (terminal dirty only) ->
_finalize_scale_request (terminal AND cleanup proven False). A POST
reply never triggers the history transition, and an adopted operation
reads the authoritative status in the same cycle.

Regressions (production GenRMScaleRegistry behind the HTTP-contract
fake, real submit/recovery methods): replay PENDING -> ACTIVE finish;
replay FAILED dirty -> status+reconcile then history with the mutex
released; PARTIAL dirty retained while reconcile is unfinished (no
premature removal, decisions frozen, server 409); replay terminal clean
completes without reconcile; status-endpoint failure keeps cleanup
UNKNOWN (retained, removal 409, decisions frozen) until a working cycle;
scale_in parity; cooldown parity normal-vs-recovered for scale_out and
scale_in; terminal UNKNOWN cleanup blocks decisions.
Evidence-provenance cleanup (review feedback):

- Remove all relative references ('final head' / 'final PR head' /
  'this PR head' / 'final-head rerun') from EVIDENCE.md; every run now
  names the commit that produced it: the seed-contract chain (score
  consistency r3, adversarial divergence r3, training smoke r3, training
  continuity r5) at 945741e; the round-1 fix batch (failure injection r2,
  autoscaler prereg r4/r5, smoke r2) at their exact heads (e7224af /
  8b0c6fe / fddb093); the preregistered v2 rounds at fa3ca97.
- State the pinning policy accordingly: the newest code exercised by any
  recorded acceptance run is 945741e; earlier verdicts are pinned to
  their own producing commits.
- Judge ground-truth correctness is now explicitly informational and
  reported per retained run (95%/95% in _r2, 97%/97% in _r3); replica
  agreement, not judge capability, is the acceptance criterion.
- Terminology: 'request-level sampling-seed contract' -> 'content-derived
  sampling-seed contract' (same content reproduces the same seed across
  replicas and repeated calls).

Artifact blobs are untouched; the SHA256 pin manifest still matches the
committed verdict files.
…-Blackwell

Final-code Autoscaler acceptance reconfirmation (Criterion 4, frozen
protocol) exposed a product regression introduced by the deterministic
sampling activation (945741e):

Reproduction (identical frozen driver, thresholds, load curve and
machine, same day):
- final code (deterministic ON): the engine wedged mid-load -- the
  scheduler stopped completing requests (served frozen at 843, 48
  requests hung, /health no answer, GPU util 0%), the elastic replica
  served 0 requests, and scale-in never became possible; the run failed.
- pre-deterministic code (8b0c6fe, r5's product code): 13/14 functional
  assertions passed, elastic replica served 461 requests in STEADY and
  automatic scale-in completed.

Root cause: SGLang applies the generic flashinfer attention-backend
default BEFORE its deterministic-inference handler. On pre-Blackwell
GPUs the handler's own deterministic fallback (fa3) is therefore
preempted, flashinfer is kept, and the radix cache is force-disabled
(flashinfer is not in SGLang's radix-supported deterministic set).
Without prefix reuse, a 48-way concurrent scoring load re-prefills every
long shared prompt until the scheduler wedges.

Fix: select the attention backend SGLang itself recommends for
deterministic inference on the detected architecture (fa3 on
pre-Blackwell, flashinfer on Blackwell+), so the radix cache stays
available. The per-request sampling-seed contract is unchanged
(deterministic mode stays on; the pytorch sampling backend still
consumes per-request seeds). Per-instance --genrm-engine-config
overrides keep the highest priority.

Regressions: deterministic attention-backend selection (fa3 pre-
Blackwell, flashinfer Blackwell+, probe failure defers to the SGLang
default), engine-config override precedence, and the server-args
pipeline carrying attention_backend alongside
enable_deterministic_inference.
FINAL_EVIDENCE_SHA = 6b501a2 (raw artifacts: the final-code confirmation
r3, the regression reproduction r2, the environment-invalid r1, the A/B
discrimination run at 8b0c6fe, and the four machine-blocked
score-consistency attempts).

EVIDENCE.md: the final-code Autoscaler confirmation row (frozen protocol,
14/14 at 5c1e2e7, deterministic sampling under the radix-preserving fa3
attention backend); the regression and environment failure records in
the failed-runs table; the pinning policy now names 5c1e2e7 as the
newest code exercised by any recorded acceptance run; the determinism
known-limit notes that fa3 keeps the radix cache available on
pre-Blackwell GPUs; the SHA256 manifest gains the final verdict blob.
…module

Suite-wide dependency-stub tests can replace sglang.srt.server_args
while relax.backends.sglang.sglang_engine is already imported, leaving
its module-level ServerArgs bound to a non-dataclass stub that
_compute_genrm_server_args cannot iterate. The two new deterministic
attention-backend tests now skip on what the engine module actually
sees (2 skips in the stubbed context, 9 passed standalone); the full
suite's failure set is byte-identical to the pre-change baseline.
Re-review P1: the three-state cleanup gate (UNKNOWN/False/True, missing
!= clean) treats every service status response without an explicit
cleanup_required as UNKNOWN, but that flag only existed in GenRM's
status contract. Rollout's ScaleOutStatusResponse and
ScaleInStatusResponse never declared it, so FastAPI's response_model
stripped the key from the wire and the shared autoscaler -- whose
default target is rollout -- read UNKNOWN for every rollout terminal
operation:

- terminal scale-out (ACTIVE/PARTIAL/FAILED/CANCELLED) and scale-in
  (COMPLETED/FAILED) stayed pending_requests forever;
- the next scaling evaluation stayed frozen ("in progress or awaiting
  cleanup") with no path to unblock;
- a removable rollout-contract target kept failing PATCH removal with
  409 (stale unresolved pending);
- history never recorded the finished operation.

Rollout has no deferred-cleanup lifecycle: a FAILED scale-out rolls its
engines back before reporting and a COMPLETED scale-in reports the
engines removed, so terminal status is cleanup-complete by contract.
The fix therefore makes the rollout status models state that explicitly
(cleanup_required: bool = False) instead of leaving the flag absent.
GenRM's semantics are unchanged: a GenRM POST reply still proves
nothing, a missing/malformed flag still reads UNKNOWN, terminal-dirty
still requires status/reconcile, and finalization still requires an
authoritative cleanup_required is False.

Coverage:
- response-model serialization tests pin model_dump()["cleanup_required"]
  is False for both scale-out and scale-in wire shapes (the response-
  model tests now import the component through tests.utils._dep_stubs
  so they actually run where transfer_queue is absent);
- shared-autoscaler regressions drive a real AutoscalerService against
  a fake speaking the rollout HTTP contract through the actual Pydantic
  response models: scale-out and scale-in terminal requests finalize
  into history, the next decision is no longer blocked, and a finalized
  target no longer 409s on removal;
- the fail-closed guard stays pinned: a rollout-contract response with
  the flag dropped (pre-fix wire shape) keeps the request pending and
  the decision frozen -- the fix lives in the contract, never in the
  autoscaler defaulting missing to clean.
…ic probe declines

Re-review P2: _compute_genrm_server_args pinned attention_backend
unconditionally -- including the architecture probe's None -- so the
sglang_* inheritance loop (which backfills only fields absent from
kwargs) could never apply the user's global --sglang-attention-backend
to a GenRM engine again. The docstring's "falls back to SGLang default"
only held for SGLang's internal default; the relax-level global flag
was silently disabled by 5c1e2e7.

The deterministic backend is now pinned only when the user has not
chosen one globally:

- explicit --sglang-attention-backend wins over the probe (restores the
  pre-5c1e2e7 inheritance behaviour);
- a failed probe defers to SGLang's default instead of pinning None
  over it (and over the user's global choice);
- the arch-appropriate deterministic backend (fa3 on pre-Blackwell,
  flashinfer on Blackwell+, radix cache preserved) is still pinned when
  the user set nothing, keeping the final-code autoscaler acceptance
  behaviour on this machine;
- per-instance --genrm-engine-config overrides still take priority over
  both (applied after the loop).

Regressions:
- an explicit global choice beats the probe result;
- a failed probe pins nothing (key absent, SGLang default applies) while
  the user's global choice still flows through the inheritance loop.
The module-level transfer_queue attribute guard (added with the rollout
contract stub support) places two imports after executable code; ruff's
E402 fires on the pushed tree and would fail the Pre-commit workflow.
Annotate both imports as guarded.
…tion PASS, r5 proxy incident, train-continuity reanalysis v2)

Evidence-branch pointer update only; no product change. Raw artifacts live on
the evidence branch @ d88d6c7 (r6/r4 verdict trees, the archived raw job log
with sha256, and the reanalysis v2 script + parser tests).
…ons table

Per review: the top of the file still pointed at evidence 6b501a2 and called
5c1e2e7 the newest acceptance code while the bottom appended 0481701
reconfirmations — an index that contradicted itself. The top is now the only
current-conclusions table: one row per official criterion, each bound to the
exact product SHA, the producing run and its raw inputs on evidence 66f1f78,
with an explicit superseded-by column. Reading rules state the caliber of
each claim (0/50 = these 50 inputs under this config; GenRM-path-only weight
sync; criterion 5 is continuity, no performance claim absent a control run;
content-deterministic semantics pending maintainer acceptance). A scope
commitments table separates delivered / not-implemented / to-be-negotiated
(cross-Gateway wording now 'not implemented this phase; interface and
responsibility to be negotiated'). The per-run history below is marked as
historical detail and keeps its own producing commits.
- docs/en/api/genrm.md + docs/zh/api/genrm.md: endpoints table, request/
  status contract, NOOP/idempotency semantics, scale-out/in state chains
  with terminal sets, cleanup_required and capacity fields (current/ready/
  occupied/pending_cleanup), strict-capacity 503 and fallback semantics
- docs/public/openapi/genrm.json: regenerate via scripts/tools/
  generate_openapi.py; 10 paths, 9 schemas, descriptions from code
  docstrings
- scripts/tools/generate_openapi.py: write trailing newline so reruns are
  byte-stable

Verified against relax/components/genrm.py, relax/utils/genrm_scale_registry.py
and relax/distributed/ray/genrm.py: every documented route, state, terminal
set, capacity formula and error code matches the implementation.
- docs/public/openapi/rollout.json: add cleanup_required (bool) to
  ScaleOutStatusResponse and ScaleInStatusResponse properties — manual
  patch in generator-faithful style (type + title, not in required),
  avoiding the unrelated /predict drift a full regeneration would bring
- docs/en/api/rollout.md + docs/zh/api/rollout.md: new 'Scale status
  cleanup contract' section (bilingual semantic parity) documenting the
  three-state contract shared with the Autoscaler (true = cleanup still
  pending; false = authoritative complete; absent = unknown, missing !=
  clean) and why rollout terminal states report false (scale-out engines
  serve or were rolled back; scale-in COMPLETED removed / FAILED rolled
  back)

Docs-only; no product code touched.
# 📝 Documentation

- Point the two evidence index links to the reviewed fdf288d snapshot.
- Leave product code, historical verdicts and experiment versions unchanged.
- Keep this commit local; the public PR head and its CI remain unchanged.
# 📝 Documentation

- Withdraw whole-run zero-error and stall-bound claims using reanalysis v2.1.
- Distinguish log-reported sample counts from file-level sample verification.
- Keep original verdicts and producing versions; mark superseded interpretations.
- Correct the long-request drain description and completed reconfirmation status.
- Preserve product code, GPU results and public PR history.
Merge main at 7c4e537 while preserving nonblocking rollout manager binding
and initial weight synchronization. Apply the upstream offline-mode guard
so SFT, DPO, and RM continue to skip startup weight updates.

Retain the English and Chinese unified inference navigation entries using
the current main formatting. Extend startup regression coverage across
online/offline loss modes and synchronous, fully asynchronous, and hybrid
execution.

Validation: selected CPU regressions passed (110 passed, 1 skipped,
2 deselected); all pre-commit hooks passed. Full Ray and multi-node GPU
validation was not rerun in the local environment.

Refs redai-studio#71
The NPU provider copies runtime arguments into the Bridge model config.
The inference placement plan contains frozen dataclasses, which the NPU
image's older Bridge sanitizer tries to rebuild with setattr during the
initial weight export, raising FrozenInstanceError on the role field.

Keep the placement plan on the runtime args and exclude it from the model
config. Preserve the other NPU argument copies and existing provider
overrides. Add GPU/NPU provider regressions that retain the original plan.

Validation: the NPU regression fails before the fix; the provider and
inference suites pass afterward (123 passed, 1 skipped because Ray is
unavailable). All pre-commit hooks pass. Real NPU validation remains with
the integration CI.

Refs redai-studio#71
Merge the GenRM elasticity implementation with the unified inference
service. Preserve legacy generation, sampling and attribution through the
gateway, count raw and streaming requests during drain, and bind closed
admission to engine identities so recovered engines may reuse endpoints.

Use the same published engine states for discovery and legacy routing.
Preserve split placement offsets, retain owned placement groups until Ray
confirms removal, and recreate elastic placements during engine recovery.
Exclude retired slots from recovery and keep failed cleanup retryable.

Reject deferred, training-offload and multi-GPU elastic layouts before
taking the lifecycle lock or allocating resources. Document the supported
scope in both languages and regenerate the GenRM API schema.

Validation: focused CPU suite 426 passed, 4 optional-dependency skips;
latest lifecycle/gateway regression rerun 34 passed (overlaps that suite);
additional contract tests 12 passed, 17 optional-dependency skips.
All pre-commit hooks and the VitePress documentation build passed.
Real SGLang and multi-node GPU execution were not rerun on this CPU host.

Refs redai-studio#71, redai-studio#351, redai-studio#356, redai-studio#370
@rai-studio-bot

Copy link
Copy Markdown
Contributor

Thanks for contributing to Relax, @Arazyu! 感谢你为 Relax 做出贡献!

📚 Contribution guide / 贡献指南

Describe the problem, your changes, and how you validated them. Keep each PR focused and run pre-commit run --all-files before submitting.

请说明问题、改动和验证方式,保持 PR 聚焦,并在提交前运行 pre-commit run --all-files。

English contribution guide · 中文贡献指南

🛠️ CI commands / CI 指令
Command / 指令 Usage / 用途
/rerun Retry failed CI / 重跑失败的 CI
/rerun <target> Rerun a workflow or check / 重跑指定 workflow 或检查
/cancel <workflow> Cancel an entire workflow / 取消整个 workflow
/help Show commands and targets / 查看指令和 target
/review Request a code review / 请求代码 review

Put one command on the first line of a new PR comment. Rerun/cancel require PR authorship or repository write access.

在新 PR 评论的首行写一条指令。PR 作者或有仓库写权限的贡献者可以重跑、取消 CI。

CI usage and targets · CI 用法与 target

🔎 Merge requirements / 合入条件

GitHub:📝 请转为 Ready for review

Requirement / 条件 Status / 状态
审批人数 ⏳ 0/1 · 至少 1 位有仓库 write 或更高权限的人 Approve
团队审批(relax-block-reviewers) ⏳ 0/1 · 可联系:Aurelius84, Yangruipis, yuanlehome, SigureMo, NINGBENZHE
CI ⏳ 等待通过

@Arazyu Arazyu closed this Oct 11, 2026
@Arazyu
Arazyu deleted the codex/integrate-genrm-inference branch October 11, 2026 09:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants