Skip to content

[CI only] config: raw-input ServerArgs stack - #36256

Closed
ch-wan wants to merge 26 commits into
mainfrom
cheng/gc-raw-input-ci
Closed

[CI only] config: raw-input ServerArgs stack#36256
ch-wan wants to merge 26 commits into
mainfrom
cheng/gc-raw-input-ci

Conversation

@ch-wan

@ch-wan ch-wan commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

One CI signal for the six-PR stack that makes ServerArgs hold the raw user input:

PR branch theme
P1 cheng/gc-p1-parallel-tier spell the parallel config tier at the call site
P2 cheng/gc-p2-publish-at-entry publishing is the process entry's job
P3 cheng/gc-p3-drop-unread-record stop handing the record to code that does not read it
P4 cheng/gc-p4-resolution-declarations resolution reads the declarations, not the fields
P5 cheng/gc-p5-readers-take-bags the runtime readers take the published bags
P6 cheng/gc-p6-raw-input the flip: declare_resolution stops writing the field

This branch is the six of them plus an empty commit, so it gets its own check suite rather
than inheriting a member's. It is not for merge and will be closed once the members
land. Review happens on the member PRs.

🤖 Generated with Claude Code


CI States

Latest PR Test (Base): 🚫 Run #32965816009
Latest PR Test (Extra): 🚫 Run #32965815741
Latest PR Test (AMD ROCm 7.2): 🚫 Run #32965816041

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 341694629f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +267 to +269
The stash *is* the resolution result: the bags are projected from it,
`resolution_result` answers from it, and no field is written. A resolver
reading a field another resolver may have decided must read `resolving_view`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the resolved loader config for encoder workers

When encoder disaggregation is used with a raw Kimi-K3 GGUF and load_format="expert_pack", prepare_raw_kimi_server_args() adds the generated pack_path, manifest, and stats paths only to the declared model_loader_extra_config. Because declarations no longer update the record, MMEncoder.__init__() still passes the raw server_args.model_loader_extra_config at disaggregation/encoder/server.py:474; with the usual raw {} value, ExpertPackModelLoader then raises that pack_path is missing even though resolution generated it. That constructor must consume the resolved model config bag/view.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d1a69f5f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

server_args = self.server_args_class(**kwargs)
self.server_args = server_args
logger.info(f"{server_args=}")
logger.info(f"server_args={server_args.resolved_dict()}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid deep-copying config just to log it

When a programmatic Engine supplies a callable config value that is not deepcopyable—such as a bound custom_sigquit_handler whose owner contains a thread lock—this eager resolved_dict() call raises TypeError before startup, even when INFO logging is disabled. resolved_dict() recursively calls copy.deepcopy() for every field, whereas the previous record repr did not; keep startup logging from materializing a deep-copied readback or use a repr-safe projection.

Useful? React with 👍 / 👎.

ch-wan added 26 commits August 26, 2026 11:51
`/server_info`, its gRPC and in-process twins, and the scheduler's
internal-state dump all handed out the record itself: three via
`dataclasses.asdict(server_args)` and one via `dict(vars(server_args))`. Both
read the fields, which carry resolution's result only for as long as
declarations materialize onto the record -- and the point of declaring is that
they will stop. Left alone, these endpoints would quietly start reporting what
the operator typed instead of what resolution decided.

`ServerArgs.resolved_dict()` is the whole-object shape of `resolution_result`:
every field, read through the declarations, nested dataclasses expanded the way
`asdict` expands them. The four exits report it. Values are unchanged today --
15 launch shapes agree field for field across all 476 -- so this is the
placement, not a new answer.

The `vars()` base was also leaking: the resolution bookkeeping (`_raw_input`,
the declaration stash, the materialization marker) and the `ModelConfig` memo
crossed IPC into `/server_info`'s `internal_states` block. The projection is
fields only, and a test pins that the dump is exactly the fields.
Three constructors published defensively because each could be built with
nothing published before it: `ModelRunner`, `TokenizerManager`, `MMEncoder`.
That made "are the config bags available here?" a question about which
constructor happened to have run, which is the wrong place for it: code between
the process entry and that constructor cannot read a bag, and a `publish` that
lands late re-projects the bags and silently drops any `override()` taken in
between.

The six entries that relied on it now publish for themselves:
`init_multi_tokenizer` (the multi-tokenizer worker reads its record from shared
memory), the two `benchmark/one_batch` work functions (run inline for
tp_size == 1 and spawned per rank otherwise), the encoder's gRPC entry, and its
spawned TP and DP workers. `publish` resolves through the idempotent gate, so a
spawned child that receives a resolved record is unaffected.

No behavior change: the constructors' `ensure_published` becomes a no-op at
each of these, and an override taken between an entry and its constructor now
survives instead of being discarded. `test_publish_precedes_bag_reads` pins all
six as publishing entries, so the walk checks each one's publish against the
bag reads it reaches.
…t publish

`ModelRunner`, `TokenizerManager` and `MMEncoder` published defensively
through `ensure_published`. With every process entry publishing for itself, a
constructor arriving unpublished no longer means "this is a standalone build" --
it means an entry was missed, and publishing here would work by accident while
re-projecting the bags over whatever the process had.

`assert_published` replaces it: same identity-and-role check, and it raises with
what is published instead, naming the entry as the place to publish. The draft
runner is unchanged (it deliberately does not publish, so it does not check
either).

The four manual tests that built one of these objects directly now publish in
their setup, which is what they are: the process entry for that test. The two
constructor entries leave `_KNOWN_ENTRIES`, and the constructor-publisher census
is down to the two that really are entries -- `Engine` and `SchedulerActor`.
Forty functions took a `server_args` and never mentioned it. Each one is a
reason for its callers to hold a record, and for *their* callers to pass one,
which is how a config object ends up threaded through call graphs that have no
config decision in them -- `require_mlp_sync`, `require_mlp_tp_gather`,
`get_cuda_graph_max_batch_size` and friends read the bags and the live topology
already.

Removing the parameter cascades: five rounds, each one making another caller's
parameter dead, until nothing was left. 104 arguments dropped at 41 call sites.
`create_offloader_from_server_args` is now `create_offloader`, since it takes
none.

Eleven functions keep theirs, and all eleven are contracts rather than dead
weight: the platform and spec-algo hooks whose base signature the pipeline
calls (`apply_server_args_defaults`, `handle_server_args`), constructors whose
siblings read the record (`BaseKVManager`, `RadixCacheCpp`,
`TokenizerMetricsCollector`, the expert-distribution gatherer), overridden
methods (`StackStrategy.build`, `launch_tensor_parallel_group`,
`load_kv_cache_scales`), `release_req`, and
`StartupWeightLoadOptions.from_server_args`, which needs a rename with it.
`StartupWeightLoadOptions.from_server_args` took a record and read sixteen
config leaves off the bags instead -- the name said the opposite of what the
body did, and the parameter kept every caller holding a record for it.

It is `from_published_config(is_draft_worker=...)` now, and the manager factory
above it `create_from_published_config`. `is_draft_worker` stays an argument:
that is this runner's role, not the process's configuration.
`EPLBManager` took a `ServerArgs` and read nine config leaves off it, all of
them `exec.moe` or `parallel`; it reads the bags now and no longer takes a
record at all. `LoRAManager` and the multimodal base processor lose the same
kind of read -- `get_lora()` / `get_mm()` / `get_serving()` -- which is also
what makes them follow a post-publish override instead of reporting startup.

What stays on a record is what has to: the base processor's `base_gpu_id`,
`tp_size` and `rl_on_policy_target` are read from *its own* instance because
engines sharing a process each have their own, and the LoRA backend below still
takes one. Four (file, field) pairs leave the supplied-instance exposure pin.
`declare_resolution` writes the field as it declares, so a resolver that reads
the field afterwards sees the declared value -- and every one of the 1130
mid-resolution field reads in this tree depends on that write. Removing it (the
last step of making `ServerArgs` hold only raw input) currently breaks
resolution outright: 20 of 20 launch shapes die on `AttributeError: 'NoneType'
object has no attribute 'prefill'`.

This is the first half of that: the readers move to `resolving_view`, a live
view that answers from the declaration stash and falls through to the field.
While the immediate write is still there the two agree, so this is a no-op --
which is exactly what makes it checkable: 20 launch shapes, every field of the
resolution result compared against the previous commit, zero differences.

337 reads in `arg_groups`: the speculative hook (146), the override passes
(103), the DeepSeek-V4 and PD-disaggregation hooks, and the three small model
hooks. `ResolvedView` stays for the post-process passes, which want the state
snapshotted at their slot; `ResolvingConfig` is for a reader that outlives a
declaration.
…ields

The other half of the readers: 779 `self.<field>` reads across the 88
resolution handlers reachable from the dispatcher now go through
`resolving_view(self)`. Same argument as the hooks -- while
`declare_resolution` still writes the field as it declares, the view and the
field agree, so this changes nothing and can be checked exactly: 20 launch
shapes, every field of the resolution result compared against the previous
commit, zero differences.

What it buys is that the pipeline no longer depends on that write to see its
own decisions. Removing it -- so the record holds the raw input and nothing
else -- needs the readers outside `arg_groups` and `ServerArgs` that resolution
calls with the record (the platform defaults, the spec-algo hook, `ModelConfig`,
the CP strategy) to move as well; those are next.
The last of the mid-resolution readers outside the pipeline's own modules:
`ModelConfig.from_server_args` (23 reads), the spec-algo hook, the adaptive-spec
support check, the CP strategy binder and the BCG predicate. They are called
*by* resolution with the record in hand, so they have the same problem as the
handlers -- a declaration-only resolver leaves the field holding the raw input.

`resolving_view` is imported inside the function at these sites: they sit under
`configs/`, `layers/` and `speculative/`, and a module-level import of
`arg_groups.overrides` there would be a new import edge into the resolution
pipeline.

`test_model_config_reads_resolved_input` learns the spelling: a local bound to
`resolving_view(sa)` / `resolved_view(sa)` / `sa._resolved()` is the record for
scanning purposes, so its two pins keep describing the reads they were written
for. 20 launch shapes, zero differences in the resolution result.
`ServerArgs`' public members read their fields, which is the same problem the
handlers had: a declaration-only resolver leaves the field holding the raw
input, so `max_speculative_num_draft_tokens`, `is_ep_joiner`,
`is_startup_weight_load_overlap`, the expert-balancedness predicates and
`describe_kv_events_publisher` could answer for what was typed instead of what
resolution decided.

They read through the view now, and the file settles on one spelling for it:
`cfg = resolving_view(self)`, replacing the `resolved = resolved_view(self)` /
`resolved = self._resolved()` mix this file had accumulated. One exception keeps
its own name -- `describe_kv_events_publisher` already binds `cfg` to a
`KVEventsConfig`, and the view has to not shadow it.
A census over the modules the pipeline actually reaches -- the registered passes
and providers plus the import map, the same derivation
`test_resolution_reads_no_bag` uses -- left 26 field reads outside `arg_groups`
and `ServerArgs`: the dLLM config builder, the experimental Marlin LoRA
validator, and the NPU platform defaults.

The NPU one is the reason to bother. `set_default_server_args` asks "did anyone
decide `page_size` yet?" before declaring its own default, and that question
has to be asked of the declarations: reading the field would answer "no" for a
size an earlier pass had already declared, and the hook would overwrite it. No
A/B on a CUDA host can catch that, which is why the census is the check here.

`configure_logger`'s single read stays: `log_level` is raw input, and that
function is called with stand-ins.
Two reads in the dispatcher itself, one of which matters: `get_device_memory_capacity(self.device)` runs right after the platform defaults declare `device`, so a field read there would size memory for `auto`. The dummy-model boundary check moves with it for uniformity.

The tests follow the same rule as the golden model-override ones: what they assert is what resolution decided, so they read `resolution_result` rather than the field -- the CPU-EAGLE overlap constraint, the dSpark draft-path default, the media-domain normalization, the multimodal piecewise-graph gates, the encoder transfer backend, and the spec-registry algorithm name. The multimodal processor fixture seeds the worker counts through `override_server_args` instead of a MagicMock, because the processor reads them from `get_mm()` now.

All no-ops today (the declaration still writes the field as it declares); they are what the flip needs in place first.
Everything that will change hands when the declarations stop writing the field,
moved ahead of the flip so it can be checked while both still agree.

`test_server_args` is the bulk of it (101 reads): what those cases assert is
what resolution decided, and `resolution_result` answers that whether or not
the declaration was written back. Four assertions go the *other* way and now
read the field on purpose -- the FA4 page-size and waterfill cases exist to show
the field staying pristine while the declaration wins, so they keep reading it
and say so.

`_comparable` in the reproducibility suite reads the projection too: comparing
fields would have stopped covering the decisions a resolution leak would shift.
The multimodal and Kimi processor fixtures seed their worker counts and cache
budget through `override_server_args` instead of a stand-in, because the
processor reads them from `get_mm()` / `get_serving()` now -- that also fixes
`test_kimi_processor_workers_clone_the_gpu_wrapper`, which the processor
conversion broke (the cache came out enabled and the fingerprint path ran into a
`SimpleNamespace` hf_config).

The supplied-instance exposure pin drops 19 entries: the model-config,
dLLM, CP, Marlin-LoRA, adaptive-spec and spec-registry reads all go through the
view now.
`resolve_image_processor_backend` already had one caller passing `get_mm()` and
three passing a record; all four pass the bag now, and the parameter is named
for what it is. The FlashInfer all-reduce fusion resolver and the draft
attention-backend fallback read their leaves directly and take no config at all
-- the fusion one was reaching for `get_server_args()`, which is a global record
read that only escaped the ratchet because it handed the whole object to a
helper. `reserve_rope_cache_for_long_sequences` reads `model.context_length` and
the two `spec` counts.

The FlashInfer fusion test drives `_resolve_backend(backend, is_multi_node)`
directly: the arch dispatch is what those cases are about, and the entry above
it now takes no arguments.

Five (file, field) pairs leave the supplied-instance exposure pin.
`init_cp_strategy(server_args)` was called from two places that cannot read the
same source: resolution calls it inside `__post_init__`, where the bags do not
exist yet, and `get_cp_strategy` calls it lazily in a worker process, where the
record is not where the resolved sizes live -- that path was reaching for
`get_server_args()` and handing the whole object over, which is how a global
record read escapes the ratchet.

It takes `enable_prefill_cp`, `cp_size` and `cp_strategy` now. Resolution passes
them off its view, the lazy path off `get_parallel().config`, and the unit tests
pass them directly instead of building a `SimpleNamespace` per case.
`resolved_embedding_plan` is the `/server_info` and gRPC readback of the
embedding runtime knobs, and it read `cuda_graph_config`,
`chunked_prefill_size`, `disable_radix_cache`, `is_embedding` and
`prefill_only_disable_kv_cache` off the record -- which now holds the raw input,
so the plan would have reported `None` for the graph config of a server running
one.

The two callers pass `resolving_view(record)`, and the parameter is `config`
rather than `server_args`: the function's contract is "something that answers
with the resolved configuration", which is why it was duck-typed to begin with.

Five (file, field) pairs leave the exposure pin, which is down to four -- the
launcher's pre-publish env setup and the auto-parser late resolution, both of
which read the record because that is the only thing that exists at those points.
All four read the record because the record is the only thing that exists where
they run: the NCCL environment setup and the auto-parser late resolution both
happen in the launcher before the publish. Written down next to the pin so the
next person does not have to re-derive it, and so a new entry has to come with
the same kind of reason.
The biggest remaining clusters of "read a field off a handed record", all of
them past their process entry's publish:

  * the encoder's five modules read `get_parallel().config.tp_size` and
    `get_serving().host` / `.port` -- `runtime.py` was already mixing
    `get_parallel().config.dp_size` with `server_args.tp_size` on one line;
  * `get_allocator_type()` reads the two HiCache leaves and takes no config,
    which drops the parameter from `_get_allocator_type` and its 14 call sites
    in the hybrid pool assembler;
  * the unified radix cache's write and prefetch policies, the detokenizer and
    gRPC metrics flags, the XPU and runner-backend memory-saver checks, the
    CP DSA split, the FP4 GEMM backend and the EP redundant-expert count.

Both exposure pins are now at their floor: four entries in `_EXPOSED` and three
in `_OVERRIDDEN_AND_READ`, all of them the launcher's pre-publish env setup and
the auto-parser late resolution.
`_setup_and_run_http_server` runs after `Engine._launch_subprocesses` has
published, so its host, port, log level and metrics flag come from
`get_serving()` / `get_observability()` -- 28 reads, including the two
`enable_metrics` gates in the app setup.

The DP controller keeps its record reads: its declared namespace set is
`{exec, parallel, device, disagg}`, so reading `serving` or `observability`
there would be refused under `SGLANG_ROLE_NAMESPACES=enforce`. Narrowing that
set was the point of declaring it, and widening it to move a `host` read is the
wrong trade.
Six sites, split by what they are actually asking. The dual-chunk attention
backend shards over the *live* group, so it reads `get_parallel().tp_size` like
the rest of the head-count arithmetic in the tree. The runner windows, the
`/v1/loads` accelerator count, the NIXL rank arithmetic and the tokenizer's
worker division all want the launch width in a process that holds no model
groups, so they read `get_parallel().config.tp_size` and are registered with
that reason.
`RayEngine` publishes as part of `Engine._launch_subprocesses` and lays the
actors out afterwards, so all 22 record reads in the two driver modules were
reading the raw input where the bag was already available -- and both files were
already mixing the two, `_compute_world_size` multiplying
`get_parallel().config.pp_size` by `server_args.tp_size` on one line. A launch
that leaves `dp_size` to resolution would have sized the placement group from
`None`.

Both modules are at zero record reads now, with a local `parallel =
get_parallel().config` where a function reads several. `_compute_world_size`
takes no argument. The four new configured-size reads are registered with their
reason: the driver is sizing the actors that will hold the process groups, so
there is nothing live to ask.

The Ray path has no CI coverage (`test/manual/test_ray_engine.py` boots a real
cluster), so `test_ray_driver_reads_the_bags` pins it three ways: the world-size
arithmetic against a published config, the same arithmetic following a
post-publish `override` -- which is what separates a bag read from a record read
-- and a file-scoped check that neither module reads a field off an instance. It
reports all 22 reads on the pre-conversion tree.

Verified against a real cluster with a cached model: `TestRayEngineOfflineTP1`
and `TestRayEngineOfflineTP2` pass (5 tests), and the custom-placement-group case
launches, serves and shuts down -- it dies afterwards on a Ray GCS teardown
timeout that `origin/main` hits identically.
Nine reads in eight files, each already mixing bag reads with a record read:
the KV configurator's memory-saver flag, the dist-init host in `bootstrap`, the
gRPC and sidecar hosts, the rust server's transport width and hosts, the
in-process HTTP engine adapter, and the MindSpore runner's host.

`initialize_moe_config` and `initialize_fp4_gemm_config` go with them, and they
change signature: both were handed a record they read resolution's answers off
(`moe_a2a_backend`, `deepep_mode`, `quantization`, the speculative pair) or, in
the fp4 case, no longer read at all. They take no argument now and read
`exec.moe` / `spec` / `model` / `exec.kernel`, which every caller has published
by the time it calls -- the scheduler, the weight-cache daemon, and the
`one_batch` work function. The five `layers/moe/utils.py` exposure pins go with
the conversion, and the three tests that used to hand it a stand-in publish one.

`configure_logger` keeps its record read, and this is the reason: it runs before
the publish in the launcher and in the encoder HTTP entry, it is called with
stand-ins, and `multimodal_gen` calls it with a *different* `ServerArgs` class
that has no bags at all. A bag read there would raise on three separate paths.

What is left of the 172 resolution-named instance reads this clearing started
from is 39, in five places, all structural: the launcher before its publish
(19), the DP controller whose declared namespace set excludes `serving` and
`observability` (14), the auto-parser late resolution (4), the multimodal
processor's per-instance `base_gpu_id`/`tp_size` (engines sharing a process each
have their own), and `configure_logger`.
… the fields

Resolution ended by applying the whole declaration stash back onto the record,
so the fields carried the resolved configuration and a post-resolution reader
could read either. That replay is what made "server_args holds the raw input"
untrue for the resolvers that only declare -- the model-specific overrides and
the registry entries, which write nothing themselves.

The replay is gone. Measured across 18 launch shapes, seven fields change hands
because of it (`attention_backend`, `disable_overlap_schedule`,
`enable_tp_lm_head_all_to_all`, `moe_a2a_backend`, `page_size`,
`sampling_backend`, `speculative_moe_runner_backend`): the record now answers
with what the operator passed and the bags answer with what resolution decided.
Every other field is unchanged, because `declare_resolution` still writes as it
declares.

The readers that were getting the resolved value off the record follow:

  * `KVCacheConfigurator` reads `get_schedule().page_size`, which is what the
    rest of that file already did.
  * `check_server_args`, `check_lora_server_args`, `_check_two_batch_overlap`,
    `describe_kv_events_publisher` and `get_attention_backends` read through
    `resolved_view(self)` -- a validator or a derived member has to answer for
    the configuration resolution decided, not for the fields.
  * `compute_world_size` takes the resolved topology (the `parallel` bag) and
    the `/get_internal_state` readback hands it one. `enable_dp_attention` and
    `dp_size` are both resolution's answers, so a raw read reported
    `dp_size * tp * pp` for a DeepSeek MLA context-parallel server that runs
    `tp * pp`. The Ray driver's copy of the formula delegates to it.
  * The MiniMax sparse backend reads `get_spec()` for the draft-token count
    and the speculative algorithm. The count is auto-filled by resolution
    (EAGLE `steps + 1`, ngram 12), so a raw read left it `None` and the NPU
    verify-metadata cache silently skipped graph capture.

`_declarations_materialized` is now `_resolution_finished`: it still arms the
read-only `__setattr__`, but there is no materialization for it to name. The
golden model-override tests move with it -- they assert the published leaf
(`config_leaf`) and the projection, which is where an override lands, instead of
the record field it used to be replayed onto.

Touching the gateway is what makes CI run its Rust lints on this stack, and the
test helpers in `cache_aware.rs` fail one the current clippy added
(`needless_borrows_for_generic_args`): the two borrows are dropped here, so the
PR that activates the job is the one that leaves it green.

The gateway's per-worker copy
moves to `replace_resolved`: it keyed off the old flag name, and with the replay
gone a plain `setattr` of `port` / `base_gpu_id` / `dp_size` is what the
read-only record refuses. Both channels are covered: the existing gateway test
keeps the older-wheel fallback, and a second one hands it a record that carries
`replace_resolved` and asserts the three per-worker values reach the child as a
declaration while the parent keeps what the operator passed.
…lution

`declare_resolution` no longer writes the field it declares. The record is what
the caller passed, the stash is what resolution decided, and the config bags are
projected from the stash -- which is what this whole series was for.

Two things fall out. The pipeline becomes reproducible over a copy: resolving a
bare `dataclasses.replace` now runs over the same input the parent got instead
of over the parent's output, so the DP-attention halving and the
conservativeness scaling apply once rather than twice. Only `random_seed`
differs, because it is generated. `replace_resolved` keeps its reason -- it
carries the parent's declarations and its `model_config`, so the copy answers
without re-deriving anything -- and the test that used to assert the drift now
asserts its absence.

And a field read inside resolution becomes a real bug rather than a latent one,
so `test_resolution_reads_the_declarations` pins it at zero over the two scopes
it can derive exactly: every `arg_groups` function that takes a config, and
every `ServerArgs` handler the dispatcher reaches. Handing a record to a helper
that loads a decided field is the shape neither an attribute scan nor a
`getattr` scan can see -- the field is spelled in the helper and the record at
the call site -- so the guard derives those helpers and pins the four spellings
that reach one: a bare name, an attribute, `get_server_args()` called inline,
and a local bound to either. It reads a decided leaf straight off such a local
too, and off a private attribute (`self._server_args.device`) or a record a
script builds for itself (`ServerArgs.from_cli_args`), which is why the scan now
covers `scripts/`, `examples/` and the gateway binding alongside the package.
One reader is pinned with its reason: the gateway falls back to the field on a
released wheel that has no `resolved_dict`. It also reads a decided leaf straight off such a
local (`alias.<leaf>`, `getattr(alias, "<leaf>")`) -- the spelling where the
leaf never appears at a call site. The spellings are pinned by a fixture rather
than by a comment: the scan runs over a sample module holding each of them plus
the legal forms next to them, so a shape cannot quietly stop being covered.

Verified by comparing the whole resolution result -- all 476 fields, 20 launch
shapes -- against the previous commit: zero differences.

Four readers outside `srt/` follow, each of which was reading a field resolution
fills in: the named-stream factory (`device` -- `torch.get_device_module(None)`
lands on CUDA whatever the host is), the gateway's worker count (`dp_size`,
which `--dwdp-size` fills, so a DWDP launch started one worker), the speculative
benchmark (`mem_fraction_static` reached the child command as the string
"None"), and the two checkpoint exporters (`model_path`, which ModelScope
resolution rewrites to the downloaded directory).
The runtime-context skill still said declarations materialize onto the fields at
the end of `__post_init__`, and that a constructor may publish. Both changed:
the record holds the raw input, resolution reads through `resolving_view` /
`resolved_view`, `initialize_moe_config` takes no record, constructors assert
instead of publishing, and a test that asserts what resolution decided reads
`resolution_result` rather than the field.
@ch-wan

ch-wan commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

The stack landed (#36250, #36251, #36252, #36253, #36254, #36255); this vehicle has no purpose now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant