Skip to content

fix(agentx): pin the draft and its acceptance when the baseline simulates acceptance - #1885

Merged
xiaofei-zheng merged 6 commits into
mainfrom
feature/leixin/pin-spec-decode-synthetic-al
Oct 15, 2026
Merged

xiaofei-zheng merged 6 commits into
mainfrom
feature/leixin/pin-spec-decode-synthetic-al

Conversation

@zengleixin-amd

@zengleixin-amd zengleixin-amd commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor
  • Description: what and why

    When a session's base launch simulates speculative-decoding acceptance instead of verifying a real draft (SGLANG_SIMULATE_ACC_LEN, a vLLM --speculative-config with rejection_sample_method: "synthetic" or a synthetic_acceptance_length, or an explicit acceptance-length flag such as ATOM's --spec-decode-acceptance-length), the acceptance length is only valid for the draft it was set for (InferenceX golden_al_distribution keys it by model, method and draft length). A candidate that changed the draft length, the method or the simulated value was benchmarked against that stale acceptance, so its "gain" was fake. Production examples: GLM-5.2 session b81d60f8 kept a variant moving EAGLE steps 5 -> 3 while SGLANG_SIMULATE_ACC_LEN=3.61 stayed (+5.8% KEEP that does not exist); MiniMax-M3 specialists proposed num_speculative_tokens 4/5 with a stale or invented synthetic_acceptance_length, stopped only by an orchestrator alert.

    History: [Feat] Deliver config levers to AgentX agentic recipes; stop injecting launcher defaults into them #1696 pinned these knobs on the AgentX agentic-recipe launch path; refactor(agentx): remove the agentic-recipe launch path #1784 removed that path and the pins with it. The generic launcher path ({framework}_{gpu}.sh + --server-args / --extra-env), which every AgentX session uses now, never had them; the rule only survived as prompt text in references/speculative_decoding.md.

    Fix: one owner, _spec_decode_pin.refuse_unpinned_speculative_change (pure), called from _grid_runner._build_variant_yaml, the function that composes every benchmarked candidate over its base (explore variants, integrate_patch _bench_patch, conc_sweep arms, specialist rebench, and the explore no-op probe). conc_sweep composes each arm's full args over the session base YAML with an empty base_extra_args, so its arms are judged against the base YAML. Specialist rebench passes the candidate's server args as base_extra_args, so only its env changes are judged there; a specialist candidate that is kept still goes through integrate_patch _bench_patch, which judges its server args. It now composes the base launch (inherited args + KEEP stack, i.e. what a no-op variant launches) alongside the candidate and, when the base simulates acceptance, refuses a candidate whose pinned settings differ, judged on the composed result:

    • pinned: --speculative-num-steps, --speculative-num-draft-tokens, --speculative-eagle-topk, --speculative-algorithm, --speculative-draft-model-path, --speculative-dspark-block-size, --num-speculative-tokens, --method, --draft-model, any --spec-decode-acceptance* / --speculative-acceptance* flag, the --speculative-config keys method, model, num_speculative_tokens, rejection_sample_method, synthetic_acceptance_length, draft_sample_method, and every SGLANG_SIMULATE_ACC_* env. Adding, changing, removing, unsetting, or a replacing argument string that drops them all count.
    • vLLM's per-key --speculative-config.<key> flags merge over the JSON object (the dotted key wins), so both spellings land in the same pinned keys; numbers are compared by value, so 3.61 and 3.610 (or 3 and 3.0) agree. The candidate is judged after its runtime_override is applied, i.e. on the launch that is benchmarked.
    • open: every other knob, including --speculative-config keys that do not describe the draft (e.g. attention_backend), as long as the variant restates the pinned keys (a --speculative-config replaces the whole object) or sets the key alone with the dotted form.
    • a base that verifies a real draft (GLM SGLANG_SIMULATE_ACC_TOKEN_MODE=real-draft-token without SGLANG_SIMULATE_ACC_LEN, Kimi rejection_sample_method: block) leaves the draft open, but its acceptance is measured, so a candidate that switches to simulated acceptance (adds SGLANG_SIMULATE_ACC_LEN or a synthetic --speculative-config) is refused: a declared acceptance is not comparable with a measured one. The message says the base measures a real draft and to keep real verification.
    • a base with no speculative decoding (no draft-describing setting at all) has no acceptance to compare against, so a candidate may enable speculative decoding either way, including with synthetic acceptance (with the golden AL for its own draft length, which the reference doc requires and no code checks yet). This is the enablement path.
    • Relation to fix(explore): refuse a context-copying speculative draft on a synthetic benchmark #1904 (real-draft acceptance cross-check against the GSM8K eval): a round that switches a real-draft base to simulated acceptance reports the same declared acceptance on the benchmark and the eval, so fix(explore): refuse a context-copying speculative draft on a synthetic benchmark #1904's check passes it; this PR owns that case and refuses it before boot. No file overlap, so whichever lands second only rebases.

    The reference doc (speculative_decoding.md) states all three cases, and its golden-AL instructions now say the AL is looked up for the launch it is set on: with a simulating base the draft is pinned, so there is nothing to re-look-up. Checking the AL a candidate sets against the golden table itself (which would let a simulating base change its draft together with a looked-up AL) needs an owner for golden_al_distribution/, which no code reads today; that is left to a follow-up, and until then the pin is equality with the base.

    run_grid records the refusal as status: failed, error_class: speculative_decoding_pinned with a message naming each changed setting (old -> new), never launches the server for it, and attaches no launch evidence. The reference doc tells the agent the refusal exists.

    Enablement: the pin is relative to the launch the candidate is composed on. An enablement round's candidates are composed on the operator's requested launch, so if that launch simulates acceptance, an enablement candidate that changes the draft is refused as well; otherwise nothing is pinned. This is deliberate: the enabled config becomes the session baseline, and letting enablement change the draft under a stale simulated acceptance would make the baseline itself fake.

  • Linked issue(s): none filed; follows up [Feat] Deliver config levers to AgentX agentic recipes; stop injecting launcher defaults into them #1696 / refactor(agentx): remove the agentic-recipe launch path #1784.

  • Tests: added src/hyperloom/orchestrator/actions/executors/tests/test_spec_decode_pin.py (35 tests): the pure rule (synthetic vLLM/DSpark/SGLang/ATOM bases refuse a change to the draft length, acceptance, method, a config that drops the draft or removes it, an acceptance env changed/unset/added, and a dotted --speculative-config.<key> over the JSON; other knobs, a non-pinned --speculative-config key, dotted keys restating the JSON, 3.61 vs 3.610, real-draft and block-verify bases are allowed; a real-draft base refuses a candidate that switches to simulated acceptance (block-verify -> synthetic, real-draft-token -> SGLANG_SIMULATE_ACC_LEN); a base with no speculative decoding allows enabling it with synthetic or simulated acceptance), the choke point (_build_variant_yaml refuses inherited-config changes, replace-mode drops, remove_args, a replacing stack's draft, an env unset; leaves a real-draft base open) and the wiring (run_grid records speculative_decoding_pinned without reaching Magpie). The wiring tests failed on the unmodified _grid_runner.py. Local (Windows): test_spec_decode_pin.py + inference_optimizer/tests/test_grid_moe_runner_backend_guard.py -> 40 passed; ruff check / ruff format --check (0.16.2) clean on touched files. CI green.

  • Size/complexity triggers crossed: _build_variant_yaml gets shorter (138 -> 137 lines, baseline tightened) with unchanged cyclomatic (19) and cognitive (37) complexity; run_grid unchanged. Code-metrics note: _grid_runner.py (already above the module-lines limit, 2247 on main) grows by one net line to 2248: the import of the new owner module and the new error class in _attach_grid_launch_evidence's never-launched set, offset by the shorter _build_variant_yaml. The rule itself lives in a new module so the backlog module only gains wiring; the baseline-raise label is applied for that one line.

  • If this simplifies or refactors: not a refactor; effective_remove is folded into the base layer that both compositions share, with the same removal semantics (a replacing base still applies only the variant's removals).

  • Observable effect: in a session whose base launch simulates acceptance, an explore variant, integrate_patch bench or conc_sweep arm (and a specialist rebench's env) that changes the draft or the simulated acceptance now fails before boot with error_class: speculative_decoding_pinned and a message naming the changed settings, instead of being benchmarked and possibly KEPT on a fake gain. Sessions whose base verifies a real draft can still tune the draft, but a candidate that turns simulated acceptance on is refused with the same error class; sessions with no speculative decoding can enable it either way. A resumed session whose KEEP stack already changed the draft keeps that stack as its base (the pin compares candidates to the stack they run on).

  • Breaking changes: no

  • PR addresses single concern: yes

  • Root cause is upstream (Magpie/TraceLens/GEAK/IntelliKit/AgentKernelArena), ticket filed: no, the rule belongs to Hyperloom's candidate composition.

🤖 Generated with Claude Code

@zengleixin-amd zengleixin-amd added the baseline-raise Waives new, worse and baseline-growth code-metrics findings; reason required in the PR description label Oct 14, 2026
Zeng and others added 3 commits October 14, 2026 19:31
…ates acceptance

A launch that simulates speculative-decoding acceptance (SGLANG_SIMULATE_ACC_LEN,
a --speculative-config with rejection_sample_method "synthetic" or a
synthetic_acceptance_length, or an explicit acceptance-length flag) fixes an
acceptance length that is only valid for the draft it was set for. A candidate
that changed the draft length, method or the simulated value was measured
against that stale acceptance and could keep a fake gain.

_build_variant_yaml, which composes every benchmarked candidate (explore
variants, integrate_patch, conc_sweep, specialist rebench) over its base, now
composes the base launch alongside the candidate and refuses the candidate
when the base simulates acceptance and the candidate's draft or acceptance
settings differ from it: the pinned flags, the pinned --speculative-config keys
and every SGLANG_SIMULATE_ACC_* env, whether added, changed, removed, unset or
dropped by a replacing argument string. run_grid records the refusal as
error_class speculative_decoding_pinned without launching the server. Other
knobs, including --speculative-config keys that do not describe the draft,
stay open, and a base that verifies a real draft pins nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
…ls in one step

Keeps _build_variant_yaml's cognitive complexity where it was and tightens its
function-lines baseline entry to the shorter body.

Co-Authored-By: Claude <noreply@anthropic.com>
A base that verifies its draft for real pinned nothing, so a candidate
could add SGLANG_SIMULATE_ACC_LEN or a synthetic --speculative-config and
be graded on an acceptance length it set rather than measured. Such a
candidate is now refused with speculative_decoding_pinned as well.

Co-Authored-By: Claude <noreply@anthropic.com>
@zengleixin-amd
zengleixin-amd force-pushed the feature/leixin/pin-spec-decode-synthetic-al branch from 8f1265a to 80c2f11 Compare October 14, 2026 11:32
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Code metrics gate: PASSED with waived findings (baseline-raise)

Judged: the 5 files this change touches. Files it does not touch never fail it; anything found there is listed under Outside this change for information.

Dimension Limit New Worse Baseline out of date Baseline entries vs base
Cyclomatic complexity fails > 20 0 0 0 72 +0
Cognitive complexity fails > 30 0 0 0 284 +0
Function length (lines) fails > 80 0 0 0 450 +0
Nested block depth fails > 5 0 0 0 27 +0
Module length (lines) warns > 800, fails > 1200 0 1 0 53 +0
Duplicated lines per file fails > 0 0 0 0 118 +0
Dead code (vulture) fails > 0 0 0 0 2 +0
Check Findings Effect
Comments 0
English only 0
Production code imports test code 0
Repeated literals 0
A1 Private name of another package 0
A8 Flag argument 0
A9 Tuple return 0
A10 Raw vocabulary comparison 0
A11 Hidden global write 0
A12 Pure forwarder 0
A12 Single-implementation abstraction 0

Worse than the baseline (waived by the baseline-raise label): 1

Unit Dimension Baseline Now
src/hyperloom/orchestrator/actions/executors/_grid_runner.py:1 Module length (lines) 2247 2248

How to fix

  • New or worse: split the unit (extract a function, a class, a module) until it is back under the limit, or no worse than its recorded value. The baseline cannot absorb a new violation: CI refuses any baseline entry added or raised relative to the base branch.
  • Baseline out of date: something improved. Run python scripts/code_metrics.py --update-baseline and commit scripts/code_metrics_baseline.txt; the command only lowers or removes entries.
  • Override: the baseline-raise PR label waives new, worse and baseline-growth findings and the failing design checks (they stay listed here). Use it only with the reason in the PR description; it waives nothing else.
  • Tools for a local run: pip install ruff==0.16.2 complexipy==8.0.1 vulture==2.16 and npm install -g jscpd@5.4.1 (or point CODE_METRICS_JSCPD at another jscpd command, such as npx --yes jscpd@<version>).
96 findings in files this change does not touch

Outside this change (informational): 96

Unit Dimension Baseline Now
src/hyperloom/orchestrator/enablement/recipe/projections.py project_launch_evidence Cognitive complexity absent 36
src/hyperloom/orchestrator/state/shared_state.py resolve_graded_comparison Cognitive complexity absent 34
src/hyperloom_kb/collect/mapping.py _experience Cognitive complexity absent 31
src/hyperloom_kb/schema.py FieldDeclaration.__post_init__ Cognitive complexity absent 32
src/kernelforge/cli/forge_loop_command.py _prepare_measurement_driver Cognitive complexity absent 36
src/kernelforge/cli/forge_loop_command.py _read_kb_warm_start Cognitive complexity absent 40
src/kernelforge/loop/runner.py IterationLoop._collect_session_candidate Cognitive complexity absent 44
src/kernelforge/loop/runner.py IterationLoop._rule_on_attempt Cognitive complexity absent 38
src/hyperloom_kb/tests/test_cli.py Duplicated lines per file absent 18
src/hyperloom_kb/tests/test_global_identity_and_pull.py Duplicated lines per file absent 13
src/hyperloom_kb/tests/test_local_state.py Duplicated lines per file absent 18
src/hyperloom/inference_optimizer/cli/__init__.py _reexport_session_environment Function length (lines) absent 112
src/hyperloom/inference_optimizer/cli/__init__.py _resolve_agent_lineup Function length (lines) absent 89
src/hyperloom/inference_optimizer/cli/__init__.py _resolve_run_topology Function length (lines) absent 92
src/hyperloom/inference_optimizer/cli/__init__.py _resume_session Function length (lines) absent 171
src/hyperloom/inference_optimizer/cli/__init__.py _seed_fresh_session Function length (lines) absent 183
src/hyperloom/inference_optimizer/multi_node/cli.py _restart_multinode Function length (lines) absent 107
src/hyperloom/inference_optimizer/multi_node/scripts/launch_multinode.py _build_parser Function length (lines) absent 84
src/hyperloom/inference_optimizer/multi_node/scripts/launch_multinode.py _spawn_rank_actors Function length (lines) absent 90
src/hyperloom_kb/http_service.py RequestHandler._dispatch Function length (lines) absent 91
src/hyperloom_kb/sync.py GlobalSync.pull Function length (lines) absent 86
src/kernelforge/cli/forge_loop_command.py _RunReporter.build_result Function length (lines) absent 91
src/kernelforge/cli/forge_loop_command.py _prepare_measurement_driver Function length (lines) absent 140
src/kernelforge/cli/forge_loop_command.py _read_kb_warm_start Function length (lines) absent 154
src/kernelforge/loop/knowledge_integration.py _measure_warmstart_field Function length (lines) absent 83

...and 71 more; the job log lists every row.

Verdict computed by the gate scripts in /home/runner/work/_temp/code-metrics-gate, not by this tree's copy.

Thresholds and sources: [tool.hyperloom.code_metrics] in pyproject.toml. Tools: complexipy 8.0.1, jscpd 5.4.1, ruff 0.16.2, vulture 2.16.

…h after its runtime override

A --speculative-config.<key> flag merges over the JSON object, so it is folded
into the same pinned keys; numbers are compared by value, so 3.61 and 3.610
agree. The check now runs after the runtime override is applied, which is the
launch that is benchmarked.

Co-Authored-By: Claude <noreply@anthropic.com>

@xiaofei-zheng xiaofei-zheng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What this PR does

When a session's base launch simulates speculative-decoding acceptance, that acceptance length is only valid for the draft it was set for, so a candidate that changed the draft was benchmarked against a stale acceptance and its gain was fake. #1696 pinned this on a launch path #1784 removed. This PR adds one pure rule, _spec_decode_pin.refuse_unpinned_speculative_change, called from _grid_runner._build_variant_yaml — the single composer every benchmarked candidate passes through — which compares the composed base launch against the composed candidate and raises before boot. run_grid records it as status: failed, error_class: speculative_decoding_pinned, naming each changed setting, and attaches no launch evidence.

Blocking issues

1. The agent's reference doc documents only half the rule, and its untouched half now instructs the agent into a guaranteed refusal.

src/hyperloom/orchestrator/prompts/references/speculative_decoding.md:75-87 conditions the whole pin on "When the session's base launch simulates acceptance". src/hyperloom/orchestrator/actions/executors/_spec_decode_pin.py:224-229 has a second branch the doc never mentions: when the base does not simulate, any candidate that turns simulated acceptance on is refused.

That branch contradicts pre-existing text in the same file, which this PR does not touch:

  • lines 29-32: "Do not invent, round, or adjust an AL / SGLANG_SIMULATE_ACC_LEN / synthetic_acceptance_length value to taste. If synthetic acceptance is used, its value must be the golden AL looked up for the exact gamma/draft length of the variant."
  • lines 37-39: "If the variant changes a draft-length / gamma / method knob, the AL reference changes with it and must be re-looked-up from the golden YAML."

Both now describe an action the code always refuses: with a simulating base the draft is pinned, so there is nothing to re-look-up; with a non-simulating base the AL may not be set at all.

Trigger — a session whose base launch carries no speculative decoding, the normal starting point for the enablement flow this doc exists for. A specialist proposes enabling EAGLE3 with the golden AL correctly looked up for its own draft length:

refuse_unpinned_speculative_change(
    "--max-num-seqs 128 --gpu-memory-utilization 0.92",
    {},
    '--max-num-seqs 128 --gpu-memory-utilization 0.92 --speculative-config '
    '{"method":"eagle3","model":"/models/MiniMax-M3-eagle3","num_speculative_tokens":3,'
    '"rejection_sample_method":"synthetic","synthetic_acceptance_length":2.78}',
    {},
)  # -> SpeculativeDecodingPinnedError

The SGLang equivalent (--speculative-algorithm EAGLE --speculative-num-steps 3 plus SGLANG_SIMULATE_ACC_LEN=2.78 on a base with neither) raises the same way. The round is spent on speculative_decoding_pinned, and the agent's own spec gives it no basis to propose something different next round.

Please state the non-simulating branch in the reference doc together with what the agent should do instead, and reconcile lines 29-32 and 37-39 with it.

2. The refusal asserts something false about a base that runs no speculative decoding.

src/hyperloom/orchestrator/actions/executors/_spec_decode_pin.py:226-229 raises "the base launch verifies its draft for real, and this candidate switches to simulated acceptance, whose length is set rather than measured. Keep real verification." That branch is taken for every base _simulates_acceptance returns False for, which includes a base with no speculative decoding at all — there is no draft being verified, and "Keep real verification" names an action that does not exist there.

This message is the only feedback the agent receives: VariantResult.error → state/failure_evidence.render_failure_line → loop/conversation.py:172, which excerpts the last 120 characters, i.e. exactly the false prescription. Please distinguish "the base verifies its draft for real" from "the base runs no speculative decoding", and name the available next step in each case.


Checked: _build_variant_yaml (the base_args/base_envs composition, the call site, the getattr(exc, "error_class") abort mapping, the never-launched set in _attach_grid_launch_evidence); _spec_decode_pin.py in full; compose_server_args / remove_server_args / _shell_safe_dedupe / tokenize_server_args_preserving_json; seal_server_argv; explore.py's no-op probe and its run_grid call sites; state/failure_evidence.py and loop/conversation.py; speculative_decoding.md in full; the eight other open PRs touching _grid_runner.py.

Verified clean and not reported: the effective_remove refactor is behaviour-preserving (remove_server_args collects specs into sets, so the dropped dict.fromkeys dedup is inert, and base_remove=[] under a replacing base yields the old list); _shell_safe_dedupe and _last_flag_values are both last-wins, so the judged args match the launched ones; the dotted --speculative-config.<key> merge wins over the JSON object in both token orderings; 3.61/3.610 and int-vs-str env values agree; a damaged candidate JSON fails closed; the secondary _build_variant_yaml call sites only run after the first build succeeded.

Ran: refuse_unpinned_speculative_change directly against the head module on 9 constructed launches (no-op, non-string env values, compact / shell-quoted / spaced JSON, a spaced-JSON method change, 3.61 vs 3.610, and the two enablement cases above). The repo's pytest suite does not run on this box (kernelforge/loop/editable_repo.py imports fcntl); CI at head 14e7297 is green on all 12 shards, both coverage jobs, code-metrics and pr-hygiene.

Base: 190604fb9d6b7c213c8e83cd8ff918b138fff59e | Head: 14e7297909e292eb17b77a31df07b64de6f979f2

@xiaofei-zheng

Copy link
Copy Markdown
Collaborator

Follow-up on the review above. This one is about the shape of the rule rather than a defect in what is written, so it is separate from the two blocking items — but it is the reason those two items are awkward to fix in isolation.

The predicate is not the invariant

The invariant this PR is defending is: a simulated acceptance length must correspond to the (model, method, draft length) cell it was looked up from.

What the code enforces is: the candidate's pinned keys must equal the base's.

The second is strictly stronger. Ran the three shapes against refuse_unpinned_speculative_change with a vLLM base of num_speculative_tokens: 3, synthetic_acceptance_length: 2.78:

candidate verdict should be
num_speculative_tokens: 5, AL copied verbatim (2.78) REFUSE refuse — stale, this is the GLM-5.2 case
num_speculative_tokens: 5, AL invented (3.21) REFUSE refuse — this is the MiniMax-M3 case
num_speculative_tokens: 5, AL re-looked-up from the golden YAML (3.21) REFUSE allow

The third row is the move prompts/references/speculative_decoding.md:37-39 instructs the agent to make: "If the variant changes a draft-length / gamma / method knob, the AL reference changes with it and must be re-looked-up from the golden YAML." It is also row 3 of test_spec_decode_pin.py's refusal parametrize (id="vllm-draft-length-and-acceptance"), so the behaviour is pinned deliberately.

Rows 2 and 3 are byte-identical inputs. The code cannot tell them apart, because it never consults the golden table. Equality is standing in for a lookup it cannot perform, and the cost is that the draft dimension — the main speculative-decoding tuning axis — is frozen for the whole session whenever the base simulates acceptance.

The non-simulating branch addresses a different claim

_spec_decode_pin.py:224-229 refuses a candidate that turns simulation on when the base does not simulate. The bug this PR fixes is "a stale AL measured against a new draft". Enabling speculative decoding from a base that has none has no stale AL — there is nothing carried over.

What that branch actually asserts is "an AL that is set rather than measured cannot support a gain". If that holds, it holds for the base too, and every session already running on SGLANG_SIMULATE_ACC_LEN is equally unsound — yet those are the sessions the rest of the rule is built to protect. Worth separating from this PR and arguing on its own.

Root cause: golden AL has no owner in this repo

grep -rn "golden_al" src/ returns the new module's comment and speculative_decoding.md. The lookup exists only as prompt text; no code reads golden_al_distribution/. Meanwhile INFERENCEX_PATH is required at runtime — cli/preflight.py:2345-2371 fails the run without it — so the YAML is reachable, just never read.

That leaves the rule with two sources: the prompt asks the agent to look it up, and this module hard-blocks on equality because nothing can check whether it did. Suggest giving it one owner — a module over $INFERENCEX_PATH/golden_al_distribution/ with (model, method, draft_length) -> AL | None — and building the gate on that:

  • candidate changes the draft: look it up. Found → accept it when the candidate's AL matches, or rewrite the AL to the looked-up value. Not found → refuse, naming the missing cell.
  • candidate's AL disagrees with the table → refuse. This separates rows 2 and 3 above on evidence rather than on equality.
  • base does not simulate: same lookup, so enablement with a correct golden AL stops being a special case.

Two things fall out. The gate stops being stricter than the invariant, and speculative_decoding.md's lookup instructions (the filename-guessing section in particular) can shrink — a rule that lives in code does not need to be restated to the agent.

If that is too large for this PR, the smaller step is to drop the non-simulating branch and loosen the predicate to "the draft and its AL must move together", which still refuses row 1 — the case that motivated the PR — without refusing row 3. The two blocking items above stand either way: whatever predicate ships has to be the one the reference doc and the refusal message describe.

…he reference

A base that does not simulate acceptance carries no simulated length a new draft
could go stale against, so a candidate enabling synthetic acceptance on it is no
longer refused (the refusal also misdescribed a base running no speculative
decoding as verifying a real draft). The reference doc states both halves of the
rule, and its golden-AL instructions no longer tell the agent to re-look-up an AL
for a draft the pin refuses to change.

Co-Authored-By: Claude <noreply@anthropic.com>
@zoroyihan7

Copy link
Copy Markdown
Collaborator

Heads-up from #1904 (fixes #1901): it is the real-draft counterpart to this PR. #1885 pins the draft when the base simulates acceptance. #1904 validates a speculative-decoding change in a synthetic session against the acceptance its own GSM8K eval measured on real text, which catches a vLLM ngram draft whose acceptance the random-prompt benchmark inflates (7.5 vs 2.5 tokens/step). There is no file overlap: #1904 touches explore.py, writeback.py, _kv_metrics.py, _subprocess_kill.py and a new _spec_decode_acceptance.py. A simulated-acceptance round reports the same acceptance on the benchmark and the eval, so it passes #1904's check and stays yours to pin. Whichever lands second only needs a rebase.

@xiaofei-zheng

Copy link
Copy Markdown
Collaborator

Re-reviewed at 83b108a. Both blocking items from the review above are resolved: speculative_decoding.md now states the condition on lines 32-39 and adds the "A base that does not simulate acceptance ... pins nothing" paragraph, and the false refusal message is gone with the branch that raised it.

My follow-up comment argued that branch away too broadly, and the deletion went one case further than it should have. Correcting that.

Where my argument was wrong

I treated _simulates_acceptance(base) == False as one situation. It covers two, with opposite meanings:

GLM real-draft-token base   _simulates_acceptance=False   draft configured: ['--speculative-algorithm', '--speculative-num-steps']
Kimi block-verify base      _simulates_acceptance=False   draft configured: ['--speculative-config.method', '--speculative-config.num_speculative_tokens', ...]
no speculative decoding     _simulates_acceptance=False   draft configured: []

My reductio — "if a set length cannot support a gain, the base is equally unsound" — only holds for the third row, where there is no acceptance figure on either side. For the first two the base's acceptance is measured, so a candidate that switches to a set length is comparing a declared number against a measured one. That is the same fake gain this PR exists to stop, pointed the other way.

The regression

Two cases in test_a_base_that_does_not_simulate_acceptance_pins_nothing now assert that it is allowed:

  • vllm-block-verify-to-synthetic — base verifies with rejection_sample_method: block, candidate switches to synthetic with synthetic_acceptance_length: 2.78.
  • sglang-real-draft-to-simulated-length — base runs SGLANG_SIMULATE_ACC_TOKEN_MODE=real-draft-token, candidate adds SGLANG_SIMULATE_ACC_LEN=4.0.

Both were refused at 14e7297. In a GLM session whose real acceptance measures, say, 2.5, a candidate declaring 4.0 reports a large gain against a base that earned its number, and is KEPT on it.

Suggested split — three states, not two

base candidate turns simulation on why
simulates acceptance must equal the base (current rule) the AL is bound to the draft it was looked up for
verifies a real draft refuse the base's acceptance is measured; the candidate's is declared
no speculative decoding allow no baseline acceptance exists, and this is the enablement path

Rows 2 and 3 are separable without a golden-AL lookup: _speculative_identity already tells them apart by whether the base carries any draft-describing key. So the original branch was right for row 2 — it just needed row 3 carved out, and a message that does not tell a base with no speculative decoding to "keep real verification".

Overlap with #1904

@zoroyihan7's note says a simulated-acceptance round reports the same acceptance on the benchmark and the eval, so it passes #1904's cross-check and "stays yours to pin". With row 2 open, that round is no longer pinned here either — #1885 returns early because the base does not simulate, and #1904 passes it because both sides report the declared value. Worth confirming between the two PRs which one owns it; on the file lists there is no conflict, so this is about coverage, not about a rebase.

…al draft's

Dropping the reverse rule also let a candidate declare an acceptance length on
a base that verifies a real draft, comparing a declared figure with a measured
one. Only a base with no speculative decoding, which has no acceptance to
compare against, may now enable simulated acceptance; the refusal says why.

Co-Authored-By: Claude <noreply@anthropic.com>
@zengleixin-amd
zengleixin-amd marked this pull request as ready for review October 15, 2026 10:31
@zengleixin-amd
zengleixin-amd requested a review from a team as a code owner October 15, 2026 10:31
@xiaofei-zheng
xiaofei-zheng dismissed their stale review October 15, 2026 10:36

Addressed at 49d243b: the reference doc now states all three base states, the refusal message distinguishes them, and the real-draft case is refused again.

@xiaofei-zheng

Copy link
Copy Markdown
Collaborator

Re-reviewed at 49d243b. Dismissed my earlier changes-requested review.

What this PR does

When a session's base launch simulates speculative-decoding acceptance, that acceptance length is only valid for the draft it was looked up for, so a candidate that changed the draft was benchmarked against a stale acceptance and its gain was fake. #1696 pinned this on a launch path #1784 removed. This PR adds one pure rule, _spec_decode_pin.refuse_unpinned_speculative_change, called from _grid_runner._build_variant_yaml — the single composer every benchmarked candidate passes through — which classifies the composed base launch into three states and refuses before boot, recorded as status: failed, error_class: speculative_decoding_pinned with no launch evidence.

Blocking issues

None.

Both items from my first review are fixed, and so is the regression the second iteration introduced. Verified the three states against 49d243b by loading the module directly:

base candidate verdict
simulates acceptance draft unchanged allow
simulates acceptance draft changed refuse
simulates acceptance draft changed + AL re-looked-up refuse (equality pin, documented as such)
verifies a real draft (GLM real-draft-token) adds SGLANG_SIMULATE_ACC_LEN refuse
verifies a real draft (GLM real-draft-token) changes the draft only allow
verifies a real draft (Kimi block) switches to synthetic refuse
verifies a real draft (Kimi block) more draft tokens allow
no speculative decoding enables it, synthetic allow
no speculative decoding enables it, real verification allow
SGLANG_SIMULATE_ACC_TOKEN_MODE set but no draft flag enables synthetic allow

The last row is the boundary _configures_a_draft turns on: a mode env with no draft-describing key is not a draft, so it falls to the enablement state rather than the real-draft one. That is the right side to land on.

Also confirmed the two claims this iteration added to the description and the reference doc: on a simulating base, a dotted --speculative-config.<key> for a non-draft key is allowed on its own without restating the object, while a dotted draft key is still refused. The description tracks 49d243b — three states, 35 tests, and the golden_al_distribution/ owner named as a follow-up with the gap stated rather than implied.

Checked: _build_variant_yaml (base/candidate composition, the call site, the getattr(exc, "error_class") abort mapping, the never-launched set); _spec_decode_pin.py in full at all three heads; compose_server_args / remove_server_args / _shell_safe_dedupe / tokenize_server_args_preserving_json; seal_server_argv; explore.py's no-op probe and run_grid call sites; failure_evidence.py and loop/conversation.py; speculative_decoding.md in full; #1904's file list for overlap (none).
Ran: refuse_unpinned_speculative_change directly against each head on 23 constructed launches across the three states, the dotted-form claims, and the numeric/quoting edge cases (3.61 vs 3.610, compact / shell-quoted / spaced JSON). CI at 49d243b is green on all 12 shards, both coverage jobs, code-metrics, CodeQL and pr-hygiene.
Base: 190604fb9d6b7c213c8e83cd8ff918b138fff59e | Head: 49d243b0b0a4ec04ce3e43f135cc8c9e762d4c0c

LGTM

@xiaofei-zheng xiaofei-zheng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking issues at 49d243b. The three base states (simulated acceptance / real-draft verification / no speculative decoding) are each verified against the module directly, the reference doc and the refusal message state the same rule the code enforces, and the description tracks the current head. Full verification detail in the comment above.

@xiaofei-zheng
xiaofei-zheng merged commit 726f341 into main Oct 15, 2026
42 of 44 checks passed
@xiaofei-zheng
xiaofei-zheng deleted the feature/leixin/pin-spec-decode-synthetic-al branch October 15, 2026 11:14
zoroyihan7 added a commit that referenced this pull request Oct 16, 2026
…te (#1885)

Acceptance refuses, with speculative_decoding_pinned, an AgentX candidate that
changes the draft or acceptance settings of a parent that simulates acceptance,
or that starts simulating, through main's refuse_unpinned_speculative_change.

Interface-Change: https://global.primus-safe.amd.com/board/boards/hyperloom-multibranch/issues/48

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

baseline-raise Waives new, worse and baseline-growth code-metrics findings; reason required in the PR description

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants