Repository navigation
fix(explore): refuse a context-copying speculative draft on a synthetic benchmark - #1904
Conversation
Code metrics gate: PASSED with waived findings (
|
| Dimension | Limit | New | Worse | Baseline out of date | Baseline entries | vs base |
|---|---|---|---|---|---|---|
| Cyclomatic complexity | fails > 20 | 0 | 1 | 0 | 72 | +0 |
| Cognitive complexity | fails > 30 | 0 | 1 | 0 | 284 | +0 |
| Function length (lines) | fails > 80 | 0 | 1 | 0 | 449 | +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): 4
| Unit | Dimension | Baseline | Now |
|---|---|---|---|
src/hyperloom/orchestrator/actions/executors/explore.py:759 ExploreExecutor.__call__ |
Cognitive complexity | 369 | 377 |
src/hyperloom/orchestrator/actions/executors/explore.py:759 ExploreExecutor.__call__ |
Cyclomatic complexity | 76 | 78 |
src/hyperloom/orchestrator/actions/executors/explore.py:759 ExploreExecutor.__call__ |
Function length (lines) | 1124 | 1156 |
src/hyperloom/orchestrator/actions/executors/explore.py:1 |
Module length (lines) | 1880 | 1965 |
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-baselineand commitscripts/code_metrics_baseline.txt; the command only lowers or removes entries. - Override: the
baseline-raisePR 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.16andnpm install -g jscpd@5.4.1(or pointCODE_METRICS_JSCPDat another jscpd command, such asnpx --yes jscpd@<version>).
Module length warning: over 800 lines, within the 1200-line failure limit (not failing): 1
| Module | Lines | Limit |
|---|---|---|
src/hyperloom/orchestrator/actions/executors/_subprocess_kill.py |
1173 | warns > 800 |
Already over at the base (backlog, not this change's): 1
| Unit | Dimension | Base | Now |
|---|---|---|---|
src/hyperloom/orchestrator/actions/executors/explore.py:576 filter_baseline_noop_variants |
Function length (lines) | 112 | 112 |
93 findings in files this change does not touch
Outside this change (informational): 93
| 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 68 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.
PR #1904 -- fix(explore): validate speculative decoding on a synthetic benchmark against its real-text acceptanceWhat it does: on the synthetic benchmark a vLLM Blocking issues: none Checked: |
|
LGTM |
xiaofei-zheng
left a comment
There was a problem hiding this comment.
No blocking issues. Verification depth is in the review comment above (checked paths, repros run, base/head comparisons).
|
@xiaofei-zheng thanks for the feedback. You're right that acceptance length differs across datasets by design, so comparing against GSM8K with a 5% threshold had no principled basis. I've reworked the PR along the lines you suggested.
The PR description and #1901 are updated. Could you take another look? |
…ic benchmark A synthetic benchmark feeds random-token prompts with ignore_eos, so the model falls into repetition loops that a draft copied from the context (vLLM ngram / ngram_gpu / suffix, or a bare `[ngram]` model; SGLang NGRAM) accepts almost entirely. The throughput gain does not carry over to real text, and accuracy gates do not catch it. On a synthetic benchmark, a variant that introduces or changes such a draft is refused before launch with `speculative_context_copy_on_synthetic`, judged against the stack the variant launches on. Model-based drafts (MTP, EAGLE, draft models), a baseline that already uses ngram, AgentX sessions and stack revalidation are unaffected. Also scan Magpie's nested benchmark_*/benchmark_stderr.log for the eval-start marker, so GSM8K eval traffic is no longer counted in the measured phase. Co-Authored-By: Claude <noreply@anthropic.com>
5c1303e to
2d80895
Compare
|
@xiaofei-zheng Your approval was on the earlier acceptance-comparison version. The PR now carries the agreed approach instead: on a synthetic benchmark a variant that introduces or changes a context-copying draft (vLLM ngram/ngram_gpu/suffix, SGLang NGRAM) is refused before launch; the 5% acceptance comparison, noise band and re-measure rule are gone; the eval-start marker fix is kept. It is rebased onto current main as a single commit (2d80895) and CI is green. Could you take another look and merge if it looks right? Thanks. |
xiaofei-zheng
left a comment
There was a problem hiding this comment.
What this PR does
On the synthetic benchmark, random-token prompts held to their full output length drive the model into repetition loops, and a speculative draft that proposes tokens by copying from the context accepts almost all of them — so explore KEPT a vLLM ngram k=7 variant at +95.7% when the gain that survives on real text is about +40%. This adds a rule module that reads the materialized launch (_spec_decode_context_copy.py) and a filter in explore's per-variant loop that refuses, before the server is launched, any candidate that introduces, switches to, or tunes a vLLM ngram/ngram_gpu/suffix or SGLang NGRAM draft in a synthetic session. Model-based drafts, a lever on a base that already copies, AgentX sessions and stack revalidations are left alone. It also makes the watchdog scan Magpie's nested benchmark_*/benchmark_stderr.log for the eval-start marker, so a round whose engine logged to the round's own server.log no longer folds its whole accuracy eval into the measured phase of kv_metrics.json.
Blocking issues: 1
- The description's test count does not match the head.
Problem: the body statesTests: src/hyperloom/inference_optimizer/tests/test_spec_decode_context_copy.py (41 tests); at head2d80895that file collects 40 (pytest --collect-only -q→40 tests collected; by hand: 20 + 9 parametrized cases plus 11 single tests).
Impact: a factual claim in the description is wrong at the current head, and the release cut aggregates these descriptions.
Action: change 41 to 40.
Checked
_spec_decode_context_copy.py (context_copy_method, context_copy_refusal, _speculative_settings, _method_in, _config_keys, _LOOSE_PAIR); explore.py (filter_context_copy_drafts, _materialized_launch, the refuse_context_copy guard, the per-variant call, both run_grid calls, the skipped_dup → per_variant_outcomes mapping, _NOOP_FILTER_ALWAYS_RUNS / _CONFIG_REPLAY_PROVENANCE, explore_grid_exhausted); _subprocess_kill.py (_resolve_scan_logs, _stale_scan_log_sizes, _scan_logs_increment, _scan_server_log_increment, the watchdog loop); _grid_runner._build_variant_yaml; grid_server_args._shell_safe_dedupe / compact_json_server_args; perf_metric.agentx_active / is_agentx_mode; framework_registry.FRAMEWORKS; writeback.py:2951; cli/parser.py:480; the other run_grid callers (integrate_patch, conc_sweep, rebench).
Ran
pytest --collect-only -qon the new test file;ruff checkon the three changed sources (clean).ruff check --select C901 --config lint.mccabe.max-complexity=20at head and at base:ExploreExecutor.__call__78 vs 76 — over the ceiling and raised, but thebaseline-raiselabel is present and the description gives the reason, so this is waived rather than reported;_communicate_with_watchdogis 26 on both sides.- Direct calls to
context_copy_refusalandfilter_context_copy_draftsfor the spaced vLLM-documented--speculative-config '{"method": "ngram", ...}'spelling viaextra_server_args, viabase_extra_argsand via a variant-suppliedEXTRA_VLLM_ARGS. The raw predicate does mis-compare that spelling (_speculative_settingssplits on whitespace while_method_inusesshlex), but_build_variant_yaml→_shell_safe_dedupecompacts every JSON blob before the filter reads it back, so all four production cases refuse correctly. Not reported for that reason. _resolve_scan_logs/_stale_scan_log_sizes/_scan_logs_incrementfor both eval-marker cases at head and at merge base0198f43: head givessaw_eval_startTrue for a nested stderr created after the stale snapshot and False for a pre-existing one; base gives False for both. The marker fix is real and fails on base.- Confirmed
exploreis the onlyrun_gridcaller that proposes speculative settings, that no accept-set keys on the newskipped_dupreason, and that the two_resolve_scan_logsconsumers are order-insensitive.
Not checked: #1032, #1154, #1276, #1675, #1869 and #1922 are too large to diff fully for overlap, so conflicts with those six were not compared. Of the overlaps that were, none touches the lines this PR adds.
xiaofei-zheng
left a comment
There was a problem hiding this comment.
The description now states 40 tests, matching what the file collects at head. No blocking issues remain.
Verified on re-check: pytest --collect-only -q on test_spec_decode_context_copy.py reports 40 collected, and the description's other factual claims trace to the head tree (the method frozensets in _spec_decode_context_copy.py, the speculative_context_copy_on_synthetic reason string, and the skip happening before either run_grid call).
Earlier review covered: the refusal rule against the materialized launch on all three routes (extra_server_args, base_extra_args, a variant-supplied EXTRA_VLLM_ARGS), including the spaced vLLM-documented JSON spelling, which _shell_safe_dedupe compacts before the filter reads it; the eval-start marker reproduced at head and at merge base 0198f43 (True/False at head, False/False at base); ruff check clean on the three changed sources; C901 at head vs base (ExploreExecutor.__call__ 78 vs 76, waived by the baseline-raise label the description justifies). All 41 checks green at 2d80895.
…eX candidate (#1904) Acceptance compares the candidate's launch with its parent's through a public hyperloom.measure.fake_gain wrapper of main's context_copy_refusal and rejects with speculative_context_copy_on_synthetic before anything launches. Interface-Change: https://global.primus-safe.amd.com/board/boards/hyperloom-multibranch/issues/48 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Description: what and why
In a synthetic-benchmark session, a speculative-decoding variant whose draft copies tokens from the context (vLLM
ngram) is KEPT on throughput that does not carry over to real text. In a 12h vLLM run (Qwen3-14B-FP8),ngramk=7 accepted 6.7-7.9 tokens per step on the random-prompt benchmark against 2.3-2.7 on the GSM8K eval the same server ran. It was reported at +95.67% when the gain that holds on real text is about +40%, which comes from fp8 KV. Details are in Speculative decoding (vLLM ngram) KEEPs on inflated acceptance in synthetic benchmark mode #1901.The benchmark seed is fixed, so the random prompts are reproducible; what inflates the draft is the workload itself. Random-token prompts held to their full output length (
ignore_eos) drive the model into repetition loops, and a draft that copies from the context accepts almost every token of a loop.Change of approach. The first version of this PR compared the benchmark's acceptance length with the GSM8K eval's and refused a candidate whose benchmark acceptance exceeded the eval's by more than the 5% noise band. Review pointed out that acceptance length differs across datasets by design, so a threshold against GSM8K is not principled: no fixed ratio separates "inflated" from "this dataset is easier to draft". The reviewer's alternative is simpler and reproducible: refuse the methods that copy from the context on synthetic data, and leave model-based drafts alone. This PR now does that.
Refuse a context-copying draft before it runs (
_spec_decode_context_copy.py, called from explore's variant loop just before each launch, against the stack as it stands then, since a KEEP earlier in the round may have changed it). In a synthetic session, a candidate is refused with reasonspeculative_context_copy_on_syntheticwhen the launch it materializes runs a context-copying draft and its speculative settings differ from its base's, i.e. it introduces such a draft, switches to one, or tunes one the base already runs. The methods, checked against the pinned engines (vLLM 0.27.0SpeculativeConfig, SGLang v0.5.21SpeculativeAlgorithm):--speculative-configmethodngram,ngram_gpu(prompt lookup) orsuffix(suffix decoding), or amodelofngram/[ngram]with no method, which vLLM resolves tongram. The JSON object and its dotted--speculative-config.<key>flags are both read, the last setting winning like the engine.--speculative-algorithm NGRAM(case-insensitive).Both launches are built with
_build_variant_yaml, the way the no-op probe andrun_gridbuild them, so a draft reached through args, replace mode, envs or a server-args env (EXTRA_VLLM_ARGS) counts. A launch that cannot be materialized is judged on the variant's own args and envs. The refusal is recorded inskipped_dup/per_variant_outcomeswith the method named, and the server is never launched for it.Not refused:
Open the eval phase (
_subprocess_kill.py, unchanged from the first version): the watchdog now scans Magpie's nestedbenchmark_*/benchmark_stderr.logforHYPERLOOM_EVAL_START. It used to reach that file only through a nestedserver.log, so when the engine logged to the round's ownserver.log(SERVER_LOG) the eval was folded intomeasured. In the run above, the warmup round's whole GSM8K eval was recorded asmeasured, so itskv_metrics.jsonKV/GPU-power figures included eval traffic.Removed from the first version: the vLLM draft/accept counters in
kv_metrics.json, the acceptance comparison and itsspeculative_acceptance_inflated/speculative_acceptance_unvalidatedrefusals, the extra noise band on speculative changes, and the rule that held a speculative KEEP unvalidated until a stack rebench. The noise band and the rebench guarded against picking the best of several noisy copying variants; with those variants refused, model-based drafts are graded like any other lever.Relation to fix(agentx): pin the draft and its acceptance when the baseline simulates acceptance #1885: complementary, no file overlap. fix(agentx): pin the draft and its acceptance when the baseline simulates acceptance #1885 pins the draft when the base simulates acceptance. This PR refuses a real copying draft on synthetic data.
Linked issue(s): fixes Speculative decoding (vLLM ngram) KEEPs on inflated acceptance in synthetic benchmark mode #1901
Tests:
src/hyperloom/inference_optimizer/tests/test_spec_decode_context_copy.py(40 tests):ngram/ngram_gpu/suffix,model: [ngram], quoted, attached, unquoted-JSON and dotted spellings, last-wins and a later--speculative-configreplacing the object; SGLangNGRAMin either case; EAGLE3, MTP, a draft model, SGLang EAGLE3/NEXTN and no draft are not copying; a copying draft inside a server-args env is found.EXTRA_VLLM_ARGSis refused; a lever on a copying base runs; after a KEEP drops the base's ngram, a later variant restoring it in the same round is refused; AgentX and a stack revalidation are not filtered; a variant that cannot be materialized is judged on its own args.suffixdropped; nomodel: [ngram]fallback; dotted keys ignored; unmaterializable variant read as empty; env values not scanned; SGLang case-sensitive;--speculative-configmerging instead of replacing; no unquoted-JSON fallback; filter not applied; judged against the round's opening base instead of the current stack; nested stderr not scanned.ruff check/ruff format --check(0.16.2) pass.Size/complexity triggers crossed: the rule lives in a new module;
explore.py(already over the module-lines limit) gains the filter function and its call, andExploreExecutor.__call__gains the per-variant check. Thebaseline-raiselabel stays for that growth.If this simplifies or refactors: not a refactor.
Observable effect: in a synthetic session, an explore variant that introduces or changes a vLLM
ngram/ngram_gpu/suffixor SGLangNGRAMdraft is skipped before launch with reasonspeculative_context_copy_on_syntheticand a message naming the method.kv_metrics.jsonnow has anevalphase on rounds whose eval marker sat in Magpie's nested stderr. Everything else is graded as before.Breaking changes: no
PR addresses single concern: yes
Root cause is upstream (Magpie/TraceLens/GEAK/IntelliKit/AgentKernelArena), ticket filed: no. The inflation comes from the synthetic workload interacting with a copying draft, and the rule belongs to Hyperloom's candidate selection.
🤖 Generated with Claude Code