Repository navigation
Conversation
vLLM ships a minor line roughly every two weeks and each one costs a day of
the same work: find what moved on our API surface, add the dependency plumbing,
chase two GPU legs green, write up what changed. The steps are mechanical; the
judgement is in the port itself and in reviewing the result.
The skill drives the whole sequence -- detect the gap, port, plumb, run the
gates, open a draft PR -- and leaves a human exactly one decision: merge or
don't.
Four things it knows that are easy to get wrong:
The gap is every unsupported line, not the newest one. The `vllm` extra is a
contiguous range claim, so going from <0.31 straight to 0.32 would claim 0.31
without ever testing it.
Gate on capability, not on version. `hasattr` states the actual requirement and
survives the next upstream rename; a version comparison needs editing every
line and silently misfires on backports. This is the repo's existing rule and
the skill does not get to relax it.
Dispatch GPU runs at the PR branch ref. The `/gpu-test` comment path hardcodes
the default branch, so a new matrix leg added in a PR would never run from a
comment -- the skill would be driving legs green that it had not actually
executed.
Never delete a dependency group. Lines leave the supported range but main's GPU
matrix may still name their groups, and `uv sync --group X` for a group that no
longer exists fails before pytest starts. That shows up as a log under 1.1 KB,
which reads like a quiet pass rather than a hard failure; both this and the
"coverage step red, job green" case are written down so the skill does not
misread its own results.
The scripts carry the parts that are tedious or error-prone by hand:
vllm_versions.py the gap, from PyPI vs the extra's claim
upstream_api_diff.py the few dozen symbols we import or override, classified
DELETED / THINNED / SIGNATURE between two tags. A
deleted hook we override is a correctness incident, not
a refactor -- that is what 0.29 did to ASR.
plumb_pyproject.py groups, the extra, and the conflicts table, which has
N*(N-1)/2*4 entries and grows quadratically. A missing
pair resolves two vLLM wheels into one venv instead of
erroring.
gpu_dispatch.sh dispatch at a ref, wait, triage
record_eval_baseline.py baselines, via the same module the check uses
preflight.sh fail before starting, not halfway through
Run state lives in a gitignored file so a session that outlives a 420-minute
GPU leg can resume instead of restarting.
.claude/skills/ is now tracked while the rest of .claude/ stays local -- a
skill only on one laptop is not automation. The pattern is `.claude/*` rather
than `.claude/`, because git does not descend into an ignored directory and the
negation would never be consulted.
Written with Claude Code.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Preflight failed an unattended run whenever the local uv differed from the uv-lock hook's rev, which it does on any Homebrew box. The failure was wrong on its own terms: the comment two lines up already said "Not fatal: `uvx uv@<rev>` can relock with the right version", and then called bad() anyway. Nothing in the skill relocks with the local uv. What actually breaks a run is the PIN being unresolvable -- offline or behind a proxy -- because then the skill falls back to the local uv, writes a different lockfile revision, and the uv-lock hook rejects the commit at the very end of a multi-hour upgrade. So check that `uvx uv@<rev> --version` works and report the local version as information. SKILL.md told the skill to run bare `uv lock` with the comment "must be uv 0.8.4 - preflight verified this". Preflight verified no such thing once the above is true, and bare `uv lock` would have run 0.7.19 here. Now the pinned form, with the instruction to read the rev from .pre-commit-config.yaml so a hook bump cannot leave the skill relocking against a stale version. Written with Claude Code. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The scored accuracy evals existed only as four hand-maintained AppWrapper YAMLs on one laptop. Their bodies were effectively identical -- the same six gates, the same harness invocation, the same verdict block -- and only the checkpoint, the accuracy bar and the eval mode differed. Four copies of a 34 KB file drift, and the drift is invisible: a fix applied to one job's render gate and not the other three leaves three jobs that cannot detect a dropped document. So the differences move into `evals.yaml` and the body is generated. Changing an adapter, a checkpoint, a bar or the cluster is now an edit to one short file rather than a four-way copy-paste. `render_eval_jobs.py` emits one AppWrapper per config for whichever vLLM line is under test. Secrets stay out. `HF_TOKEN` and `GITHUB_COM_TOKEN` are read from the environment at render time and land only in the rendered output, which goes to the gitignored `scratch/eval-jobs/`; the renderer warns and writes placeholders when they are unset rather than silently producing jobs that die at the clone. Two properties of the generated job are worth naming because they are the reason it can run unattended: Each job asserts the checkpoint it actually loaded has (or has not) routed experts. Mounting the G5 path for the 4.2 job produces a plausible score for the wrong model -- a duplicate run masquerading as independent coverage -- and nothing else in the pipeline would notice. The render gate runs before any model loads and asserts the document text is present in the rendered prompt and that the longest prompt fits MAX_MODEL_LEN. Both failures are silent: documents dropped from the prompt land the score at the ~0.40 unanswerable base rate, which looks exactly like a bad adapter, and vLLM truncates an over-long prompt without complaint. Catching either costs seconds instead of hours of GPU. SKILL.md now states why there are two eval tiers rather than one. The `tests/eval/` suite measures distribution drift on 4 prompts in minutes and catches "the adapter stopped firing"; these jobs measure accuracy on 3565 rows against a fixed bar in hours and catch "the model got worse". A model that answers confidently and wrongly has a perfectly normal top-k distribution, so the cheap check cannot see the second failure. The PR-body template gains the matching scored-accuracy table. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The skill's promise was spread across nine phases, so the thing it actually
guarantees was not written down anywhere: compare released vLLM against the
supported ceiling, port every line in the gap, verify each line, and hand back
a reviewable PR. SKILL.md now opens with that contract, and the autonomy
boundary is explicit -- branching, editing, relocking, pushing, dispatching and
opening the PR are pre-authorized by invoking the skill. There are four reasons
to stop and ask, and merging is never the skill's to do.
The substantive change is per-version evidence. The GPU matrix carries two legs
at the ends of the supported range, which is the right standing configuration
but the wrong one during an upgrade: adding 0.31 and 0.32 under a two-leg
matrix tests 0.32 and infers 0.31. So the matrix is widened to one leg per
added line for the duration of the PR, the per-leg results are recorded as they
land, and a final commit narrows it back to the ends. That last commit is also
what makes pyproject's existing "verified out of band" comment true rather than
aspirational.
The scored evals get the same treatment -- rendered and submitted once per added
line rather than once per PR, each with its own --out directory. Without the
per-line directory the loop's `kubectl apply -f <dir>` resubmits the previous
line's four jobs, since job names are unique but the directory is not.
The PR body templates follow: a coverage table with one column per added line
where a tick means that gate ran on that line, and a regression table carrying
each line's accuracy and delta against the unchanged bars. A reviewer should not
have to take "the suite is green" on faith for a version that never had a leg.
Three things in here would have failed at runtime:
The eval jobs installed `.[hf,vllm,compose]` and let pip choose from the `vllm`
extra's RANGE, so a job rendered for 0.31 would install whatever the newest
in-range line was and then die at its own version gate. The pin now travels in
the same pip invocation as the extra.
SKILL.md told the operator to run `plumb_pyproject.py --ceiling 0.32`; there is
no such flag, as the ceiling is derived from the highest --add. And Phase 3
invoked `upstream_api_diff.sh` when the file is `.py`.
REFERENCE.md gains the matrix-leg mechanics it was missing: entries are
{label, dep_group}, the dep_group is the dev flavour because the bare groups
omit pytest, and the floor's dev flavour is bare `dev` rather than `dev-vllm26`.
A leg whose group is absent from the dispatched ref dies before pytest with a
sub-1.1-KB log that reads like a test failure.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The drift evals existed as pytest files but nothing could reach them. They carry an `eval` marker that pyproject never declared, so pytest warned on it, and the GPU workflow had no `eval` scope, so there was no way to ask a pod to run them. Both halves are needed: the marker makes `-m eval` meaningful and the workflow arm makes it dispatchable, including via /gpu-test-eval (the slash-command handler derives families from the `options:` line, so it needs no edit of its own). The arm pins `-m "eval and not deep"` rather than a bare `-m eval` for the same reason the `audio` arm does: pytest's command-line -m REPLACES addopts' `-m "not deep"` instead of intersecting with it, so the short form would quietly start running the expensive deep cases every other scope excludes. Baselines are not recorded yet -- tests/eval/baselines/ holds only its README -- so the suite is wired but not yet green; recording them needs the adapter checkpoints staged on a pod. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The skill told itself to "widen the GPU matrix" for each newly supported vLLM line. There is no matrix to widen. The legs are derived at run time by /opt/gsw/derive_legs.py, which lives in the runner image, reads the tested commit's own pyproject.toml, and emits at most two entries -- one from `dev` (the floor) and one from `dev-latest` (the ceiling alias). No pull request edit can produce a third leg, so the old instruction could never have run. Per-line coverage instead comes from one commit per added line: each commit repoints `dev-latest` at the next line and is dispatched at its own sha. The workflow's concurrency group is keyed on sha, so those dispatches run in parallel rather than cancelling one another, and the floor is re-tested on every round because it is always the lower leg. The last commit lands the alias on the true ceiling, which is also the state that should merge. plumb_pyproject.py grows --point-latest to walk the alias forward and --check --final to refuse an alias parked mid-range, which would otherwise leave the top supported line permanently untested by CI. --floor now moves all three names that identify the floor: the vllm extra's lower bound, `dev`'s include-group, and default-groups. Missing the last one is not untidy but fatal -- a default group below the extra's lower bound makes `uv sync` unsatisfiable, the failure pyproject's own comment records. The conflicts table keeps its dev-latest pairs even though uv infers them transitively (verified: `uv sync --no-default-groups --group vllm31 --group dev-latest` still refuses without them). They stay because the written table should be truthful on its face and because --check compares against main's committed form. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Phase 2 opened with `git checkout main`. On any clone where the skill is not yet committed to main -- which is every clone today -- that removes .claude/skills/upgrade-vllm/ from the working tree, and an unattended run has no Phase 3 onward left to read. The branch is created and then the run ends mid-flight. Branch at origin/main instead, which never puts main in the tree, and assert the skill file still exists afterwards so the failure is reported rather than silently truncating the run. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
…selines Two false greens, both of which would have let an unattended run start and then fail deep into the GPU phase. The suite check grepped the LOCAL gpu-tests.yaml. workflow_dispatch always executes the workflow file from the DEFAULT BRANCH, whatever ref or sha is being tested -- the property pyproject's `vllm27` comment already records for the leg matrix. So a feature branch that adds a suite passes the check while a dispatch of that suite is rejected with `unknown suite`. That is the current state exactly: `eval` is on this branch and not on main. Read the file from origin/<default-branch> instead, and say which branch the verdict is about. Second, a dispatchable eval suite is still useless with no recorded baselines: tier-1 compares against committed numbers, so with none the legs cannot be green and the PR body's drift column has no source. Count the files, excluding the directory's own README.md, which is documentation and not a baseline. Preflight now fails honestly on this tree, which is the point -- both gaps are real and block a trial run. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Two corrections, both from dispatching the eval suite for real (run 39299824463) instead of reasoning about it. First, revert the preflight change that read gpu-tests.yaml from the default branch. The premise was that workflow_dispatch always executes main's copy of the workflow, so a branch-only suite could not be dispatched. That is false. The run dispatched suite=eval -- an option present only on this branch -- with --ref on this branch, and it was accepted, derived its legs, and resolved TEST_PATHS to `-m "eval and not deep" tests/eval/`. GitHub validates the choice list against the dispatched ref. What is actually true about main is narrower: gpu_test_command.sh chooses --ref <default branch>, so the /gpu-test COMMENT path uses main's file. That is a deliberate property of the comment path, not a platform limit, and this skill does not use it. The comment now records the run id so the next reader does not re-derive the wrong conclusion. Second, the thing that run did expose. It came back GREEN having collected 4 eval tests and skipped all 4 for want of staged checkpoints -- pytest exits 0 on an all-skipped suite, the pod prints ALL GPU TESTS PASSED, and the workflow posts success. gpu_dispatch.sh took the green conclusion and exited 0 without reading the log, so the skill would have stamped a per-version checkmark for a suite that evaluated nothing. That is precisely the false coverage claim the per-line design exists to prevent. On success the log is now read and a non-zero `N passed` is required; a green run that only skipped, or that published no artifact at all, is reported as VACUOUS/UNPROVEN and exits 1. Verified both ways against the real log from that run and against a genuine "12 passed, 1 skipped" summary. Preflight additionally counts tier-1 baselines, excluding the directory's own README.md, since a dispatchable eval suite with nothing to compare against cannot be green for the right reason. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The eval gate could not run anywhere. Every config required its checkpoint to be pre-staged under GSW_EVAL_*_DIR, and nothing sets those variables on the GPU pod -- so all four tests skipped, pytest exited 0, the pod printed ALL GPU TESTS PASSED and the workflow posted success. Run 39299824463 is the evidence: 4 collected, 4 skipped, 0.03s, green. An eval gate that cannot run is worse than no gate, because a reviewer reads it as coverage. Staged paths still win when present, for a pod holding a large or non-public checkpoint. Otherwise the config is composed from public ibm-granite repos into the same cache root tests/hf/test_multi_switch_e2e.py already uses, so a checkpoint built by either side is warm-reused rather than built twice in one leg, and no new artifact is introduced -- these are the base and adapters the e2e suites already download. sr_vllm intentionally gets no recipe. Its dual-stream adapters are not public, and composing a non-SR checkpoint for it would evaluate the wrong thing while reporting SR coverage. There is no second env var guarding the compose. The e2e suites need their GRANITE_SWITCH_E2E_MODELS opt-in because a plain `pytest tests/` reaches them; these evals are excluded from the default selection by the `eval` marker, so asking for the suite IS the opt-in. It is also the only workable design: the pod is launched by /opt/gsw/submit_and_poll.sh from the runner image, which cannot be handed a new flag from this repo, and a gate nothing can satisfy is exactly how the suite came to skip permanently. GSW_EVAL_NO_COMPOSE restores the old staged-only behaviour for anyone who wants it. Compose failure raises rather than skipping: a recipe that stopped working is a real break and must not read as an absent checkpoint. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
record_eval_baseline.py runs standalone, so it never loads tests/conftest.py and never got VLLM_USE_FLASHINFER_SAMPLER=0. On a GPU pod that meant the recorder booted vLLM with the FlashInfer sampler, which JIT-compiles through ninja; the build failed and the engine core never started, so no baseline could be recorded at all. The eval CHECK runs under pytest and therefore had the sampler OFF. Recording and checking differing in engine configuration is exactly what sharing _eval_core.py is supposed to prevent, so the default belongs here, where both sides pick it up. Still setdefault, so an image with a working toolchain can opt back in. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Recorded on an A100 from a known-good line (vLLM 0.30.0, torch 2.13.0, transformers 5.17.0) with checkpoints composed from public ibm-granite repos. Three of the four configs: granite42_vllm and granite42_hf against 'factuality-correction', granite5_vllm against 'answerability'. sr_vllm is absent because its dual-stream adapters are not public, so it keeps skipping rather than being compared to a non-SR model. Each file carries the checkpoint fingerprint and the versions it was recorded against, so a baseline silently describing a different checkpoint is caught instead of quietly passing. These are what makes the eval gate real: until now it had nothing to compare against, so the suite failed with "there is nothing to compare against" even once it could obtain a checkpoint. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The granite42_vllm eval failed against its OWN recording line (vLLM 0.30) with mean JSD 0.95 on prompt 3, and failed byte-identically on 0.26 -- which ruled out both version drift and non-determinism. The cause was the measurement, not the model. The gate compared position- aligned distributions across a self-conditioning greedy rollout, so one flipped near-tie at position 1 (`527` vs `16`) put the two runs on different sentences and every later position compared unrelated contexts. The numbers proved it: mean JSD 0.953644 and mean(1-Jaccard) 0.9583 are both exactly 23/24, the fraction of positions at or after the flip, while every position before it agreed to JSD < 0.01. Tolerating that tie is the entire reason this metric is distributional rather than token-exact -- the fused SWITCH kernel's float-reduction order differs from vLLM's native linear -- so a rollout gate cannot do its job: it cannot separate "this line computes different logits" from "this line broke a tie the other way". Both the recording and the check now force the baseline's recorded continuation through the model and read prompt_logprobs at each position, so every compared position has a byte-identical prefix. Only recording generates. Verified against the two committed captures of the same checkpoint: forcing takes prompt 3 from mean JSD 0.9536 to 0.0028. A real logit or weight regression still moves the distribution at the first position it touches. Forcing the same continuation through the base pass also aligns the liveness comparison by construction: it now asks what the un-adapted model predicts at the adapter's own contexts. compare() rejects a pre-forcing baseline rather than comparing against it, and a short prompt_logprobs raises instead of being averaged over whatever positions survived. The existing baselines must be re-recorded; the format changed, not the verdict. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Same checkpoints, same adapters, same fingerprints (89d9b2f9da91e834, da2c066319fb00d3) and the same known-good line as before -- vLLM 0.30.0 / torch 2.13.0 / transformers 5.17.0 on an A100. Only the measurement changed, so these carry a `continuations` key and the distributions are read at those forced positions rather than over a self-conditioning rollout. The old files could not be kept: compare() rejects a baseline without `continuations` rather than comparing a forced capture against a rollout one. Validated before committing: 4 prompts x 24 positions per pass, every captured position exactly 20 wide, and 91-97% of forced adapter argmaxes reproduce the greedily generated continuation token. That last number is the offset check -- a wrong index would score near zero -- and the few misses are the near-tie flips between batched prefill and incremental decode that the distribution metric is designed to tolerate. Adapter effect is 0.167 / 0.225 / 0.165, well clear of the 0.01 liveness floor, so the adapters are measurably firing in all three. sr_vllm still has no baseline by design: its dual-stream adapters are not public, so it has no compose recipe and keeps skipping. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Teacher forcing fixed the trajectory cascade -- granite42_vllm's prompt 3 went from mean JSD 0.9536 to 0.0000, and granite42_hf now reproduces its baseline at exactly 0.000000 on every prompt (run 39556314476). What remained was a small residual that threshold-watching would have misread as drift. It is not drift: granite5_vllm's prompts 1-3 are byte-identical on the 0.26 and 0.30 legs (0.000642 / 0.002751 / 0.003302), so nothing version-dependent is moving. But prompt 0 -- the longest, and the only one that moved -- differs BETWEEN those two legs in both vLLM configs (granite42_vllm 0.000000 -> 0.001857, granite5_vllm 0.001914 -> 0.003221). Same GPU model on both legs and on the recorder, so that is not hardware class either. Prefix caching explains exactly that shape. The adapter and base passes differ only by a leading control token, so they share nearly every block, and whether a position is recomputed or served from a cached block depends on what already ran in that engine. The capture therefore depended on pass order, which is precisely what a baseline must not do. Turning it off makes the measurement a function of the checkpoint and the forced tokens alone. The bar for accepting the re-recorded baselines that follow is that the check reproduces them at 0.000000 -- stronger than the thresholds, which are deliberately left untouched. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Same checkpoints and fingerprints, same known-good line (vLLM 0.30.0 / torch 2.13.0 / transformers 5.17.0, A100). granite42_hf is byte-identical to the previous recording, which is the expected result and a useful check: the HF backend never had a prefix cache, so only the two vLLM configs move. granite5_vllm's prompt 3 now ends after 18 tokens instead of 24 -- it reaches EOS on its own once cached blocks are not in play. The capture handles a short continuation already (every position is driven by len(continuation)). Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Run 39569446055 failed both granite42 configs on one GPU leg with "baseline
evaluates adapter 'factuality-correction' but the checkpoint now offers
'factuality-detection' first", while the other leg of the same commit composed
the same recipe in the opposite order. The structural guard did its job -- it
refused to compare two different adapters and call the difference a regression
-- but the eval should not have been order-dependent to begin with.
The order is genuinely unstable: `discover_adapters` accumulates into a dict
keyed by name while walking `root_path.rglob("*/*/*/io.yaml")` and returns
`list(...values())` with no sort, and `compose_granite_switch` extends its
adapter list with that result as-is. Its sibling `list_available_adapters`
sorts the same walk, so this looks like an oversight rather than a deliberate
ordering. Consequence beyond the evals: adapter indices and control-token
assignments for a given recipe are not reproducible across machines. Worth
fixing in the composer separately -- not done here, because changing the order
changes every existing checkpoint's token mapping.
The eval now selects its adapter by NAME via EVAL_ADAPTERS, so it measures the
same adapter wherever the checkpoint was composed, and raises if the named
adapter is absent instead of falling back to index 0 and reporting the
substitution as drift.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
A baseline is only a reference if the environment doing the checking can reproduce it, and a Vela-recorded one could not: run 39569446055 missed it by a systematic ~0.004 mean JSD on both vLLM configs, identically on the 0.26 and 0.30 legs. Identical across versions rules out version drift. It is also not torch, which I assumed first and was wrong about -- granite42_hf reproduces its Vela baseline at exactly 0.000000 on the torch-2.11 leg against a torch-2.13 recording, so the toolchain gap is demonstrably not what moves these numbers. Something else in the vLLM capture path differs between the two machines. Rather than keep guessing at which, record where the check runs: the baseline is then reproducible by construction and later movement is attributable to the vLLM line, which is the only comparison this gate is for. Adds a `suite=eval-record` scope and tests/eval/test_record_baselines.py. It is a recording job reachable through pytest because pytest is the only thing submit_and_poll.sh runs on the pod and that script is baked into the runner image. It prints each baseline between --8<-- / -->8-- markers for a human to extract and commit, and writes nothing into the repo: a job that could rewrite a baseline in place is one bad dispatch away from erasing the thing being gated. It also asserts the capture is well-formed, so a broken recording fails instead of printing something unusable. Two selection details that would otherwise bite: * the cases are marked `deep` as well as `eval_record`, so pyproject's default `-m "not deep"` cannot reach them -- which is why this is the one scope whose -m does NOT intersect "and not deep". Doing so would select nothing and the leg would pass vacuously. * eval-record bypasses the result cache. Its entire output is the printed JSON, and a cache hit skips the pod, so a repeat dispatch would report success with nothing to extract. Verified locally: `-m eval_record` collects exactly the 4 recorders and the default selection still collects only the 4 checks; the scope arm emits the right PATHS/LEG for eval-record and still rejects an unknown suite. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Extracted from run 39578693370 (suite=eval-record), which recorded 3 configs and skipped sr_vllm on both legs. These come from the vllm30 leg -- the known-good ceiling -- so the baseline and the check now share one environment. granite42_hf is unchanged from the Vela recording, byte-for-byte: the only difference is the dropped trailing newline. That is a useful control. It says the HF capture path is fully deterministic across both machines AND across the two legs (0.000000 on all four prompts, 0.26 vs 0.30), which is what rules torch out as the source of the vLLM offset -- the 0.26 leg carries torch 2.11 against these torch 2.13 recordings and still reproduces exactly. Validated before committing: 4 prompts x 24 positions, every captured position 20 wide, and 92-94 of 96 forced argmaxes reproduce the generated continuation. Note on reading cross-leg numbers: each recording leg generates its OWN continuation, so diffing two recordings prompt-by-prompt re-creates the trajectory cascade and is meaningless (it showed 0.95 on prompts whose continuations diverged). Cross-version drift is only measurable the way the gate measures it -- one committed baseline, both legs forced through it. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The eval gate failed `granite5_vllm` at mean_jsd 0.003472 > 0.003 on the vLLM 0.26 leg of run 39587132597 while passing on 0.30. That looked like a version regression and was not one. `granite42_vllm` read exactly 0.000000 on the same 0.26 leg and 0.001857 on 0.30 -- the line the baseline was recorded on -- which is backwards for version drift, and run 39569446055 produced byte-identical granite5 numbers on both legs. A difference that is the same on both lines cannot be a property of either line. Cause: `_pairwise` swept k over (1, 5, 10, 20) with TOP_K == 20, so one of the four sweeps compared the captures over the full capture WINDOW, and the worst k is the one reported. The window's edge churns between runs. A token ranked just inside one capture's top-20 and just outside the other's enters the compared union present on one side and absent on the other, and `jsd_bits` renormalizes over the union, so that token contributes roughly its entire probability mass in bits no matter how small the logit difference that moved it across the boundary was. It is an artifact of truncating at 20, not a distribution difference. Measured on the two `eval-record` recordings of run 39578693370, which capture the same checkpoint on 0.26 and 0.30 and so isolate the line. On granite5_vllm prompt 0, ~3 asymmetric tokens per position holding 0.0157 of the mass produced mean JSD 0.016248 at k=20 against 0.000378 at k<=10 over the same positions -- 43x, none of it distribution movement. Restricting the support to the two captures' intersection instead of capping k closes almost none of the gap (0.000664), so the k cap is the load-bearing change and the restriction would be redundant; it is not added. Fix: sweep only k < TOP_K, as the new `COMPARE_KS`. Every compared token is then ranked well inside both captures, so its presence is not itself a measurement. Thresholds are unchanged and no baseline is re-recorded -- the committed 0.30-recorded baselines now read mean_jsd 0.000423 / 0.002121 / 0.000000 for the three configs against the 0.26 capture, inside the 0.003 gate on the metric's own merits. Sensitivity is retained, which is the thing worth checking before believing a fix that makes a failure go away. Against the calibrated corruptions a 30% logit scaling still reads mean JSD 0.011 and a single-position top-2 swap reads max JSD 0.72, versus gates of 0.003 and 0.05. `granite42_hf` remains exactly 0.000000 on all four prompts across 0.26/torch 2.11 and 0.30/torch 2.13, so the gate has not been loosened into blindness. tests/unit/test_eval_metric_truncation.py builds the pathological pair by hand on CPU -- two captures agreeing on every retained logprob and disagreeing only on which near-tie token fell outside the window -- and asserts the configured sweep reports exactly zero for it while still tripping on both corruptions. Three of its six cases fail against the previous (1, 5, 10, 20) sweep, so they discriminate rather than merely pass. It also pins k < TOP_K directly and rejects a GSW_EVAL_TOP_K set below the whole sweep, which would have dropped every k and made the eval pass without measuring anything. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The skill's tier-1 section still told a future run to record baselines locally with `record_eval_baseline.py`, which is no longer the sanctioned path, and said nothing about the two properties of the measurement that took four GPU runs to establish. A skill that documents the gate incorrectly is worse than one that is silent about it: it instructs the next run to reintroduce the bugs. Document, with the reasoning rather than just the rule, so a future run can judge an edge case instead of pattern-matching: - The measurement is teacher-forced, and must not be "simplified" into a greedy rollout. Includes the (N - p)/N arithmetic signature that identifies a rollout-cascade false positive, so it is recognized rather than re-derived. - The comparison sweeps only k < TOP_K, because at k == TOP_K it measures the capture window's own edge churn. - The adapter is pinned by name, never by index, because the composer walks rglob unsorted. - Prefix caching stays off in the capture path, for reproducibility not speed. - Never widen a threshold, alongside the existing never-re-record rule. Plus the tell that distinguishes the two cases: a failure that reproduces identically across vLLM lines, INCLUDING the line the baseline was recorded on, is a defect in the measurement, not stale data. That is exactly how both false positives were caught, and it is the cheapest available check. Baselines are now recorded on the GPU runner via `suite=eval-record`, so the baseline is reproducible by construction and later movement is attributable to the one thing that changed. The old script keeps working for a dev box with checkpoints staged, and its docstring now says it is not the sanctioned path and why -- a Vela-recorded baseline missed the CI runner by a systematic ~0.004 mean JSD on both lines, which ruled out both version drift and torch. gpu_dispatch.sh's usage grows the `eval-record` option with a note that it RECORDS rather than CHECKS, so nobody reaches for it to clear a failing eval. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
A reviewer reading "mean JSD 0.002" has no way to know whether that number describes distribution movement or an artifact, and the two false positives this gate produced were both artifacts that looked exactly like regressions. Say in the body what the number means: teacher-forced on the baseline's continuation, swept at k < TOP_K, and recorded on the GPU runner rather than a dev pod. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The skill is ~400 lines of procedure across SKILL.md and REFERENCE.md, which is the right form for a run to follow and the wrong form for a human deciding whether to trust it. This page covers the ten phases, why per-line GPU evidence needs one commit per line, and the two eval tiers. It leads with the non-obvious parts rather than the happy path: that a green run is not proof a gate ran, that --ref and --sha mean different things, and that an eval failure reproducing identically across lines is a defect in the metric. Those are the three things that produced wrong results before they were fixed, so they are what a reviewer should know the pipeline handles. The footer names SKILL.md, REFERENCE.md and _eval_core.py as the sources of record and says they win on disagreement -- a summary that drifts is worse than no summary, and this one will drift. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
`upstream_api_diff.py` builds regexes and hands them to `git grep -E`, which is POSIX ERE. POSIX ERE has no `\s`, `\w` or `\d` shorthands -- and `git grep` does not error on them, it just matches nothing. Both patterns in the script used `\s`, so `defs_at()` returned 0 definitions for every symbol at every tag, every comparison was 0-to-0, and the report concluded "nothing moved" no matter which pair of tags it was given. That makes the script worse than useless: a clean report is exactly what Phase 3 of the skill reads as "no port work needed", so a silent-breakage upgrade would have been blessed. Any prior upgrade this script cleared was never actually checked. Caught with a positive control rather than by inspection: REFERENCE.md §3 documents that vLLM 0.29 deleted `BaseMultiModalProcessor._call_hf_processor`, so v0.28.0 -> v0.29.0 has a known non-empty answer. The script reported nothing. With `[[:space:]]` substituted for `\s` the same invocation reports `_call_hf_processor DELETED (74 -> 0)`, which is the documented truth. Replaces `\s` with `[[:space:]]` in `defs_at()` and `symbol_exists()`, and records the trap in a NOTE above each pattern so the next edit does not reintroduce a Perl-ism. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
vLLM 0.31 reintroduced `BaseMultiModalProcessor._call_hf_processor`, which 0.29
had deleted -- but with a different contract. There are now three eras of this
surface, not two:
A (0.26-0.28) _call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs)
returns input_ids AND the audio tensors
B (0.29-0.30) hook absent; _apply_hf_processor_main(mm_items,
hf_processor_mm_kwargs) is the only seam
C (0.31-0.32) _call_hf_processor(hf_data, hf_kwargs) returns the audio
tensors ONLY; _get_hf_mm_data replaced by the
_get_hf_mm_inputs / _finalize_hf_mm_data pair
The existing gate was `hasattr(super(), "_call_hf_processor")` with a comment
reading `# 0.26 - 0.28`. That predicate is true again on 0.31+, so eras A and C
collapsed onto one branch -- the branch written for A's signature. Era C passes
`hf_kwargs=`, while our era-B path reads `kwargs["hf_processor_mm_kwargs"]`, so
a misread era surfaces as a KeyError on the audio path at best, and at worst as
the 0.29 failure mode all over again: an override that is never called, no
transcript, and every test still green. REFERENCE.md §3 calls that the canonical
worst case for a reason -- it is how this exact hook broke last time.
`hasattr` cannot separate A from C because both HAVE the attribute. So the gate
now reads the hook's *signature*: `_base_hook_era()` inspects the first
non-self parameter -- `prompt` means era A, anything else means era C -- and
falls back to probing for `_get_hf_mm_inputs` when the hook is not
introspectable. Still a capability check (operating rule 1): no version number
appears anywhere, and a line that ships a fourth shape will be classified by
what it looks like rather than by what it is numbered.
`_call_hf_processor` is correspondingly rewritten as `(self, *args, **kwargs)`
handling both A and C, and `_apply_hf_processor_main` self-extracts only in era
B, where there is no hook to re-enter.
The test asserts the *effect*, not the call, since a never-called override is
the failure being guarded against. It defines stub parents matching each
released signature and checks that exactly one era self-extracts. Two of the six
cases exist specifically because `hasattr` passes them:
`test_era_a_and_c_are_distinguished` and `test_only_era_b_self_extracts`.
Negative control: reverting `_base_hook_era` to `hasattr` semantics fails 3 of
the 6.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Widens the supported range from 0.26-0.30 to 0.26-0.32. The `vllm` extra's upper bound moves to <0.33.0, and each new line gets a `vllmNN` pin plus a `dev-vllmNN` flavour. No group is removed -- `vllm27` and `dev-vllm27` stay, because a group is a leg that some commit's `dev-latest` can name and `derive_legs.py` resolves groups against the *tested* commit, so deleting one makes that leg's `uv sync --group X` fail before pytest starts (REFERENCE.md §4). The floor is unchanged at 0.26: nothing the port now requires is missing there, so no supported line is dropped. `[tool.uv] conflicts` is regenerated rather than extended, by `plumb_pyproject.py`. The table needs an entry for every pair of groups that could pull two vLLM lines into one venv; five declared lines with a bare and a dev flavour, plus `dev-latest` as a third name for whichever line it aliases, comes to 48 entries where the previous three lines needed 16. A *missing* pair does not fail loudly -- it lets `uv sync` resolve two vLLM wheels into one environment -- which is why the table is generated and `--check` verifies it. `dev-latest` points at **0.31, deliberately not the ceiling**, because this is the first commit of the per-line evidence walk. The GPU workflow has no matrix to widen: `/opt/gsw/derive_legs.py` is baked into the runner image and emits at most two legs, read from the tested commit's `dev` and `dev-latest`. So the only way each added line gets its own green run is for each added line to BE `dev-latest` at some commit, dispatched at that commit's sha -- workflow concurrency is sha-keyed, so those dispatches run in parallel instead of cancelling one another. This commit buys the floor + 0.31 legs; the next walks the alias to 0.32. `plumb_pyproject.py --check --final` refuses to pass while the alias is parked here, so the branch cannot merge in this state. No dependency ceiling had to rise. PyPI metadata for the new lines asks for transformers >=5.10.4,<5.18.0 (0.31) and >=5.16.1,<5.20.0 (0.32); our existing `transformers>=5.16,<=5.17.0` sits inside both, and all three lines pin torch==2.13.0, which satisfies `torch>=2.11.0`. Relocked with uv 0.8.4, the rev of the `uv-lock` pre-commit hook. Prose ranges updated wherever they are stated: README's install note, tutorials/PREREQUISITES.md, docs/AUDIO.md (plus a `dev-vllm32` sync example), and the stale "claims 0.26-0.30" comments in pyproject itself. The whitespace in docs/SWITCH_KERNELS.md is the trailing-whitespace hook, not an edit. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Second and last step of the per-line evidence walk. The previous commit parked `dev-latest` on 0.31 so that the GPU run dispatched at *its* sha would derive a 0.31 upper leg; this commit moves the alias to 0.32 so a run dispatched here derives a 0.32 upper leg. Both runs exist simultaneously because the workflow's concurrency group is keyed on `inputs.sha`, so the second dispatch does not cancel the first. The floor (0.26) is the lower leg on both, which is why it needs no commit of its own. This is also the state the branch has to merge in. CI's upper leg is whatever `dev-latest` names, so merging with the alias on an intermediate line would leave 0.32 -- the top line the `vllm` extra claims -- permanently untested. `plumb_pyproject.py --check --final` now passes, and it is the gate that says so. Nothing is deleted: `dev-vllm31` stays a declared group even though the alias has moved off it, both because rule 2 forbids removing a leg some commit still names and because 0.31 remains inside the supported range. The conflicts table is regenerated rather than hand-edited, which is the real reason this is a script and not a one-line edit. `dev-latest` is a *third* group name for whichever line it aliases, so its six pairs have to move from 0.31's block to 0.32's; leaving them behind would both state a falsehood (that `vllm31` conflicts with `dev-latest`) and drop the pairs that stop `uv sync --group vllm31 --group dev-latest` resolving two vLLM wheels into one venv. Relocked with uv 0.8.4. The alias's prose comment is updated to name 0.32 and to record that the 0.31 parking spot was the walk, not an oversight. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
✅ GPU tests passed —
|
✅ GPU tests passed —
|
✅ GPU tests passed —
|
✅ GPU tests passed —
|
✅ GPU tests passed —
|
✅ GPU tests passed —
|
✅ GPU tests passed —
|
✅ GPU tests passed —
|
✅ GPU tests passed —
|
vLLM 0.31 normalizes the modality key before calling `_call_hf_processor`:
`_get_hf_mm_inputs` ends with
# HF processors accept (images, text, videos, audio)
# so we need to remap vLLM's "audios" -> HF "audio"
if "audios" in hf_data:
hf_data["audio"] = hf_data.pop("audios")
(vllm/multimodal/processing/processor.py, v0.31.0 and v0.32.0; absent at
v0.30.0). Our era-C branch read `hf_data.get("audios")`, which by that point
no longer exists, so `_audio_features` was handed an empty clip list on every
request and returned `{}` -- no transcription at all, on exactly the lines
this port exists to support.
This is the 0.29 failure mode wearing a different hat. Reading the wrong key
does not raise: `.get` returns the default, the hook hands back an empty
BatchFeature, and the request dies much later and somewhere else -- in
`_get_prompt_updates`, as
IndexError: index 1 is out of bounds for dimension 0 with size 1
a cumsum over an empty `audio_num_tokens` indexed for an audio item vLLM
knows exists. Signature on the 0.31 GPU audio leg: 13 integration tests
ERRORing at engine init during `profile_run`, with the real cause three
frames up and no mention of audio anywhere in the message. The floor leg
(0.26) was green in the same run, which is the tell that the fault is
era-specific rather than in the transcriber.
Both keys are accepted rather than just the new one, so that if upstream
moves or drops the normalization the failure cannot come back as silence.
Era A is unaffected: it is handed the raw mm_data, where the key is still
plural.
Found by materializing the v0.31.0 tag and reading `_get_hf_mm_inputs`
directly, rather than by trusting that an empty clip list meant no audio.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The audio tests invoked `_call_hf_processor` and `_apply_hf_processor_main`
with one era's parameter names hardcoded, so they passed on the lines whose
shape they happened to match and raised on the others. On the 0.31 GPU audio
leg that was 11 failures against a green 0.26 floor leg in the same run:
KeyError: 'hf_data' (x6)
TypeError: BaseMultiModalProcessor._apply_hf_processor_main()
got an unexpected keyword argument 'hf_processor_mm_kwargs' (x5)
Neither is a product bug -- the src port handles all three eras -- but the
suite cannot report on a line it cannot run on, and "the tests error" is not
distinguishable from "the feature is broken" without reading each traceback.
Two seams now dispatch on era:
* `_call_hook` builds the call for whichever shape is live: era A gets
`(prompt, mm_data={"audios": ...}, mm_kwargs, tok_kwargs)`, era C gets
`(hf_data={"audio": ...}, hf_kwargs)` -- singular, because
`_get_hf_mm_inputs` renames the key before the hook sees it, so passing
the plural form would exercise a path production never takes. Era B has
no such hook and skips with a reason naming what covers it instead.
* `_apply_uncached` grew a third branch. 0.31 renamed
`hf_processor_mm_kwargs` -> `hf_kwargs` AND collapsed
`_maybe_apply_prompt_updates`'s four loose arguments into a single
`MultiModalProcessingResult(prompt_ids, kwargs, hashes, prompt_updates)`.
Both are reshapes of the same chain, so both live in the same helper
rather than being spread across the six tests that use it.
Dispatch reuses the module's own `_base_hook_era` rather than
reimplementing the discriminator. A second copy would be free to drift from
the first, and then these tests would agree with themselves while
disagreeing with production -- which is the specific thing a capability
check is supposed to rule out.
The previous gate here was `hasattr(proc, "_apply_hf_processor_text_mm")`,
which is still correct for the A/B split it was written for; it is replaced
only because it cannot express a third era.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The two preceding commits fix an era-C read of the multimodal data and the era-shaped test call sites, but they land AFTER the per-line walk, so neither of the walk's shas contains them: d08fa78 (alias on 0.31) and eb83c33 (alias on 0.32) both predate the fix. derive_legs.py reads the tested commit, so re-dispatching at those shas would re-run the broken tree and report the same 11 failures. Rather than amend the walk -- which would rewrite two shas already dispatched and already linked from the PR body -- the alias is walked forward again from here. The history reads as a second lap, which is what actually happened, and each lap's evidence stays attached to the sha that produced it. The alias comment is rewritten with it. It claimed 0.32 while the alias names 0.31, and a comment asserting the wrong line is worse here than no comment: this is the one place a reader checks to find out which line CI's upper leg will install. Not a mergeable state -- `--check --final` fails while the alias is parked below the ceiling. The next commit lands it back on 0.32. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Second lap of the per-line walk, carrying the audio fixes that the first lap's shas predate. This is the state `--check --final` accepts: the alias is on the ceiling of the `vllm` extra's range, so CI's upper leg installs the top supported line on every future run. The alias comment moves with it, for the same reason it moved in the previous commit -- it names the line, and a reader checking which line the upper leg installs has nowhere else to look. Verified: declared lines : 0.26, 0.27, 0.30, 0.31, 0.32 (floor 0.26) GPU legs : dev -> 0.26, dev-latest -> 0.32 conflicts : 48 entries, 48 required Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
vLLM 0.32 removed cascade attention (upstream e1a508625, "[Attention]
Remove cascade attention", #61126). That deleted five fields from
`FlashAttentionMetadata`, all of them required positional fields with no
defaults: `use_cascade`, `common_prefix_len`, `cu_prefix_query_lens`,
`prefix_kv_lens` and `suffix_kv_lens`.
Five GPU harnesses build that dataclass by hand -- they drive a real
FlashAttention kernel rather than a stub -- and each passed all five to
say "no cascade, this is one plain prefill". On 0.32 every one of those
constructions raises
TypeError: FlashAttentionMetadata.__init__() got an unexpected
keyword argument 'use_cascade'
which the worker reports as `vLLM multi worker cannot start`, so the
failure arrives as 71 failed tests across tests/vllm/ and
tests/integration/ rather than as one import error. The vllm31 leg of the
same dispatch is unaffected, which is what identifies this as the 0.32
removal and not a change of ours.
The fields cannot simply be dropped either: on 0.26-0.31 they have no
defaults, so omitting them is the same TypeError in the other direction.
So `tests/shared/vllm_kv_cache.py` -- already the home for "build real
FlashAttention inputs on any supported line" -- gains `cascade_kwargs()`,
which returns the five fields when the dataclass still declares them and
`{}` when it does not, and the five sites splat it. The gate is on
`FlashAttentionMetadata.__dataclass_fields__`, i.e. the shape of the API,
not on `vllm.__version__`.
No behaviour changes on any line. The values passed were exactly "cascade
off", which is the only thing 0.32 can do, so a harness that omits them
there gets the attention it has always asked for.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
`granite-speech-5.0-470m-turboctc` was re-uploaded on 2026-10-21
("Upload 13 files", "Remove legacy custom-code files") and now truecases
and punctuates its output: the committed test clip comes back as "What is
the capital of Israel?" where it used to be "what is the capital of
israel". `ASRTranscriber.transcribe()` pins no revision, so every runner
picked the new weights up at once and all four transcript assertions
started failing together.
This is not the vLLM port. The four failures are identical on the vllm26
floor leg and on vllm31 and vllm32, the lockfile resolves the same
transformers 5.17.0 / torch 2.11.0 as the base branch, and neither
`asr.py` nor this test is touched by this branch -- the checkpoint changed
underneath an unpinned reference.
Casing and terminal punctuation are the ASR model's editorial choice, not
a contract we own, so comparing them was never what these tests were for:
they exist to show that our pipeline routes audio through the CTC backend
(no generate(), decode kwargs dropped), that the dtype override still
transcribes, and that the chunked path neither drops nor duplicates words
at the seams. A `_words()` helper lowercases and strips non-alphanumerics,
and the assertions compare word lists.
This keeps the tests strict about content while no longer breaking when
upstream re-renders the same sentence. The word SEQUENCE is still exact --
a transcript of "what is the capital of france" fails as before -- and the
chunked case still requires the final word to survive the merge, which is
the assertion that actually catches a seam bug. Pinning a revision instead
would freeze the test onto weights a served deployment would not be using.
Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The two fixes in the preceding commits -- the 0.32 cascade-attention metadata removal and the re-uploaded CTC checkpoint's new orthography -- landed after the previous per-line walk, so neither of that walk's shas contains them. `derive_legs.py` reads the TESTED commit, so evidence for a line only exists at a commit whose `dev-latest` points at it; the earlier shas can only ever prove what the tree looked like before the fixes. Amending them is not an option: they are already dispatched and already linked from PR #151, and the skill's rule is to add a commit rather than rewrite one. So `dev-latest` walks 0.32 -> 0.31 -> 0.32 once more, giving each line a fresh sha that contains both fixes. The next commit lands the alias back on 0.32, the merge-ready state. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Lands the alias on the true ceiling of the `vllm` extra's range, which is the only state that may merge: CI's upper GPU leg is derived from `dev-latest`, so merging with it parked on an intermediate line would leave 0.32 -- the top line the extra claims -- permanently untested. `plumb_pyproject.py --check --final` enforces this and now passes. This sha carries both of this lap's fixes (the 0.32 cascade-attention metadata removal and the CTC checkpoint's new orthography), so the 0.32 leg dispatched here is evidence for the merged tree rather than for a tree that predates them. Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
The skill is internal tooling, not part of what granite-switch publishes, so it moves to generative-computing/granite-switch-internal and is removed here. This drops the eleven files under .claude/skills/upgrade-vllm/ and the docs/UPGRADE_VLLM_PIPELINE.html page that documents them, and reverts the `!.claude/skills/` negation in .gitignore that existed only to track them -- with no committed skills left, `.claude/*` plus the settings.json negation is again the whole rule. What stays public is deliberate: tests/eval/ (the drift gate, its three committed baselines and its prompt set) and the `eval` / `eval-record` arms of .github/workflows/gpu-tests.yaml. Those are repository test infrastructure that any contributor needs in order to reproduce or re-record a baseline, and they do not depend on the skill -- the skill merely dispatched them. All six drift-eval results in this PR were produced by that workflow, so removing the skill does not weaken any evidence here. Five places referenced the deleted path and would otherwise have become dead pointers. Three are pyproject.toml comments explaining the two-leg GPU walk and the generated `[tool.uv] conflicts` table; they now name the internal repo, so a reader can still find the generator. The other two mattered more, because they told a human how to record a baseline by running a script this commit deletes: tests/eval/test_adapter_evals.py's assertion message and tests/eval/baselines/README.md now give the `-f suite=eval-record` workflow dispatch instead. That is a better instruction regardless of where the skill lives -- a baseline has to be captured on the same runner that checks it, since one recorded elsewhere missed by a systematic ~0.004 mean JSD on both vLLM configs, identically on the 0.26 and 0.30 legs. No dependency, source or test behaviour changes, so uv.lock is untouched and the twelve GPU legs and eight scored evals already collected remain valid. Assisted-by: Claude Code Signed-off-by: itzikvaisman <yizhak.vaisman@ibm.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This PR targets
maindirectly, and theupgrade-vllmskill is not in it.The skill is internal tooling and now lives in
generative-computing/granite-switch-internal; the final commit here removes itfrom the public tree, along with the
docs/UPGRADE_VLLM_PIPELINE.htmlpage thatdocumented it.
What is in this PR is the port itself plus the test infrastructure it needed:
tests/eval/(the drift gate, its three committed baselines and its prompt set)and the
eval/eval-recordarms ofgpu-tests.yaml. Those are repository testinfrastructure — a contributor needs them to reproduce or re-record a baseline —
and they do not depend on the skill, which only dispatched them. Every drift-eval
result in the tables below came from that workflow, so no evidence here rests on
code the last commit removed.
Supported vLLM range
[vllm]extra>=0.26.0,<0.31.0>=0.26.0,<0.33.0vllm26/vllm30vllm26/vllm32New lines in this PR: 0.31 and 0.32.
Support dropped
None — the range only widened. Every line previously claimed is still claimed
and still resolvable. The floor stays at 0.26: nothing the port now requires is
missing there, so no
[vllm]lower bound moved and no downstream pin has to.No dependency group was removed either.
vllm27/dev-vllm27andvllm31/dev-vllm31are all still declared even thoughdev-latestdoes notpoint at them — a declared group is a GPU leg some commit can name, and
derive_legs.pyresolves groups against the tested commit, so deleting onemakes that leg's
uv sync --group Xfail before pytest starts (REFERENCE.md §4).Dependency ceilings touched
transformers: unchanged at>=5.16,<=5.17.0. PyPI metadata asks for>=5.10.4,<5.18.0(0.31) and>=5.16.1,<5.20.0(0.32); our existing windowsits inside both, and uv resolved 5.17.0 for every leg.
torch: unchanged at>=2.11.0. All of 0.30 / 0.31 / 0.32 pintorch==2.13.0, which satisfies it.tokenizers: not declared here; 0.32 raises its own floor to>=0.23.2,resolved transitively.
So this upgrade is a pyproject-and-one-source-file change, which is the intended
shape — but only because the one source file was found. See below.
What upstream changed, and how it is handled
BaseMultiModalProcessor._call_hf_processor— reintroduced with a new contractUpstream: 0.29 deleted the hook (REFERENCE.md §3 records this as the canonical
worst case for this repo). 0.31 brought it back — with a different signature:
(hf_data, hf_kwargs)returning the modality tensors only, where the 0.26-0.28hook took
(prompt, mm_data, mm_kwargs, tokenization_kwargs)and returnedinput_idsas well. 0.31 also replaced_get_hf_mm_datawith the_get_hf_mm_inputs/_finalize_hf_mm_datapair, and renamed_apply_hf_processor_main'shf_processor_mm_kwargskwarg tohf_kwargs.There are therefore three eras of this surface, not two:
_call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs)→ ids + tensors_apply_hf_processor_mainis the only seam_call_hf_processor(hf_data, hf_kwargs)→ tensors onlyHere: our gate was
hasattr(super(), "_call_hf_processor")with a commentreading
# 0.26 - 0.28. That predicate is true again on 0.31+, so eras A andC collapsed onto one branch — the branch written for A's signature. The era-B
path reads
kwargs["hf_processor_mm_kwargs"], which 0.32 no longer passes, so amisread era is a
KeyErroron the audio path at best. At worst it is the 0.29failure all over again: an override that is never called, no transcript, and
every test still green.
Fix:
_base_hook_era()gates on the hook's shape rather than itsexistence — it inspects the first non-
selfparameter (prompt⇒ era A,anything else ⇒ era C) and falls back to probing for
_get_hf_mm_inputswhen thehook is not introspectable. Still a capability check: no version number appears
anywhere in
src/, and a line shipping a fourth shape gets classified by what itlooks like rather than by what it is numbered.
hasattrstructurally cannot dothis, because A and C both have the attribute.
Test:
tests/unit/test_audio_processor_hook_era.py, 6 cases. It asserts theeffect — which era self-extracts — not that a call happened, because a
never-called override is precisely the failure being guarded. Two cases exist
only because
hasattrpasses them:test_era_a_and_c_are_distinguishedandtest_only_era_b_self_extracts. Negative control: reverting_base_hook_eratohasattrsemantics fails 3 of the 6.Bounding the port: the processor's other overrides
(
_get_mm_fields_config,_get_prompt_updates,_postprocess_prompt,_apply_prompt_updates) are unchanged between 0.30 and 0.32, and the otherload-bearing surfaces from REFERENCE.md §3 — the MoE topk
is_paddingmask,AttentionBackendKV-cache shape helpers,vllm.Attention,LLM/EngineArgskwargs, the
vllm.general_pluginsentry point — show no deletion, rename orsignature narrowing across 0.30 → 0.32.
That bound held for
src/, but not for the test harnesses, and the nextfinding is the one the static pass could not have produced.
FlashAttentionMetadata— cascade attention removed at 0.32Upstream:
e1a508625("[Attention] Remove cascade attention", vllm#61126)removes cascade attention outright at 0.32, and with it five required,
no-default fields of
FlashAttentionMetadata:use_cascade,common_prefix_len,cu_prefix_query_lens,prefix_kv_lens,suffix_kv_lens.Here: five GPU harnesses build that dataclass by hand — they drive a real
FlashAttention kernel rather than a stub — and each passed all five fields to
mean "no cascade, one plain prefill". On 0.32 every construction raises
TypeError: FlashAttentionMetadata.__init__() got an unexpected keyword argument 'use_cascade'. The worker reports that asvLLM multi worker cannot start, soit lands as 67 failing tests across
tests/vllm/andtests/integration/rather than as one error — worth stating, because the symptom reads like a broad
model regression and is in fact one deleted kwarg.
Dropping the five fields is equally wrong in the other direction: on 0.26-0.31
they have no defaults, so omitting them is the same
TypeError.Fix:
cascade_kwargs()intests/shared/vllm_kv_cache.py— already the homefor "build real FlashAttention inputs on any supported line" — returns the five
fields when the dataclass still declares them and
{}when it does not; the fivesites splat it. The gate is
FlashAttentionMetadata.__dataclass_fields__, i.e.the shape of the API, not
vllm.__version__. No behaviour changes on anyline: the values passed were exactly "cascade off", which is the only mode 0.32
has, so a harness that omits them there gets the attention it always asked for.
How it was attributed: the
vllm31leg of the same dispatch is unaffected,and the 0.26 floor leg shows none of these 67 failures — which is what identifies
this as the 0.32 removal rather than a change of ours.
Why Phase 3 did not predict it, and what that says about the detector.
upstream_api_diff.pyreports symbols that were deleted, renamed, or had theirsignature narrowed.
FlashAttentionMetadatasuffered none of those: the class isstill there, still constructible, and its
__init__is generated — the breakingchange is the field list of a dataclass, which no symbol-level diff sees.
This is a known gap rather than a bug to fix in passing, and it is why the GPU
legs are the gate and the static pass is only a map.
Not a vLLM finding: the CTC checkpoint was re-uploaded mid-flight
Disclosed because it is a change in this PR that a reviewer would otherwise read
as a test being weakened.
ibm-granite/granite-speech-5.0-470m-turboctcwas re-uploaded on 2026-10-21("Upload 13 files", "Remove legacy custom-code files") and now truecases and
punctuates: the committed clip returns
"What is the capital of Israel?"whereit used to return
"what is the capital of israel".ASRTranscriber.transcribe()pins no revision, so every runner picked the newweights up at once and all four transcript assertions in
tests/integration/test_asr_ctc_default_gpu.pyfailed together.This is not the port. The four failures are identical on the
vllm26floorleg and on
vllm31andvllm32;uv.lockresolves the same transformers 5.17.0and torch 2.11.0 as the base branch; and neither
asr.pynor that test istouched by this branch. The cross-leg identity is the tell — a version-specific
break cannot be identical on the floor.
Fix: a
_words()normalizer (lowercase, strip non-alphanumerics), with theassertions comparing word lists. Casing and terminal punctuation are the ASR
model's editorial choice, not a contract this repo owns, and were never what
these tests were for: they exist to show that audio routes through the CTC
backend (no
generate(), decode kwargs dropped), that the dtype override stilltranscribes, and that the chunked path neither drops nor duplicates words at the
seams. The word sequence is still exact, so a transcript of
"what is the capital of france"fails exactly as before, and the chunked casestill requires the final word to survive the merge — the assertion that actually
catches a seam bug. Pinning a revision was rejected: it would freeze the test
onto weights a served deployment would not be using.
The detector that was supposed to find all of this reported nothing
Worth its own section, because it is the reason the above was nearly missed and
it ships as its own commit.
Here:
upstream_api_diff.pybuilds regexes and hands them togit grep -E,which is POSIX ERE. POSIX ERE has no
\s/\w/\d, andgit grepdoesnot error on them — it silently matches nothing. Both patterns used
\s, soevery symbol reported zero definitions at every tag, every comparison was 0-to-0,
and the report concluded "nothing moved" for any pair of tags. That is worse than
useless: a clean report is exactly what Phase 3 reads as "no port work needed".
Any previous upgrade this script cleared was never actually checked.
Fix:
[[:space:]]for\s, with a NOTE above each pattern so the next editdoes not reintroduce a Perl-ism.
How it was caught: by refusing to accept an empty result as evidence and
running a positive control with a documented answer. REFERENCE.md §3 records
that 0.29 deleted
_call_hf_processor, sov0.28.0 → v0.29.0must be non-empty.The script said nothing had changed. After the fix the same invocation reports
_call_hf_processor DELETED (74 → 0).Per-version coverage
One column per line this PR adds, plus the floor and the previous ceiling as
controls. ✅ means that gate ran and passed on that line;
—means it did notrun or does not apply. No cell is ticked by inference from its neighbours.
full(unit, composer, hf, vllm, integration)audiogranite42_vllmgranite5_vllmsr_vllmgranite42_hfEvery leg's own log header states which line it installed —
Deps: dev (vllm 0.26.x)on the floor leg,Deps: dev-latest (vllm 0.31.x)/(vllm 0.32.x)onthe new ones — and the vLLM banner inside confirms it (
V1 LLM engine (v0.31.0),(v0.32.0)). That header is the receipt that a column is the line it claims;without it, two cells could be the same leg reported twice.
On the one-test count difference, stated rather than smoothed over. The new
lines report 2867 passed / 75 skipped where the floor reports 2868 / 74, and 203
passed / 1 skipped where the floor reports 204. It is the same single test in
both suites —
tests/vllm/test_audio_processor.py::TestPromptUpdatesAreApplied::test_uncached_path_reports_updates_not_applied— which
pytest.skips when vLLM has no_apply_hf_processor_text_mm, deletedupstream at 0.29. Its guard comes from
7da02b0, an ancestor oforigin/main,so the previous ceiling 0.30 skipped it too: this is pre-existing behaviour,
not coverage lost by this port. Mentioned because a differing pass count between
columns is exactly the kind of thing that should be explained rather than left
for a reviewer to wonder about.
¹ Pre-commit ran both locally over all files (
uvx pre-commit run --all-files)and in CI, run
40297204607
(49s, pass). Like the CPU matrix it installs no vLLM, so it does not vary by line
and the same ✅ fills every column.
² 0.30 gets no GPU leg on this branch:
derive_legs.pyemits at most two legsper commit, and both commits here spend theirs on the floor and on a new line.
0.30 remains a declared, supported line and is bracketed by two tested lines, but
this PR does not re-prove it — hence
—, not ✅.³ One run covers all four columns, because the CPU matrix installs no vLLM at
all.
ci.yamlsyncs a CPU-only environment, so there is no per-line CPU leg torun and the same ✅ is the honest entry in every column — it is not four
independent measurements. Run
40297204607:
314 passed / 1449 skipped on Python 3.11 and 3.12, read from the
Run CPU testsstep (not the job's step list — "Upload coverage" iscontinue-on-error: trueand can be red under a green job). Non-vacuous: 314 isa real pass count, not an all-skip.
This row read
—until the skill-removal commit, because the PR previouslytargeted
feature/vllm-upgrade-skilland bothci.yamlanddco.yamlfilteron: pull_request: branches: [main]— a filter GitHub evaluates against thebase branch, so neither was eligible and the PR showed zero checks. Now that
it targets
mainthe matrix actually ran, and DCO passes with all 13 commitssigned.
Per-line CPU coverage still comes from the GPU legs, which do install vLLM: the
fullsuite runstests/unit/as part of "everything" (1854 unit test lines ineach leg's log), so the unit suite is exercised on each of 0.26 / 0.31 / 0.32
under its own wheel.
⁴ The floor is the lower leg of every dispatch, so it is re-proven six times
over. Cited above from the runs at
806aced(full40177367456,
audio
40177402752,
eval
40177436576)
and
c841131(full40177881390,
audio
40177916472,
eval
40177952586).
⁵ 0.31 at sha
806aced, legvllm31: full40177367456
(3:43:08), audio
40177402752,
eval
40177436576.
⁶ 0.32 at sha
c841131, legvllm32: full40177881390
(3:48:11), audio
40177916472,
eval
40177952586.
⁷
sr_vllmis skipped by design on every line, including the floor and theprevious ceiling — not a hole this PR opened. SR adapters are not public, so
there is no compose recipe and no committed baseline (
tests/eval/baselines/holds
granite42_hf,granite42_vllm,granite5_vllmonly). The suite's owndocstring defines the split: no checkpoint and no recipe ⇒ SKIP, whereas a
checkpoint with no baseline ⇒ FAIL. So
3 passed, 1 skippedis this suite'sfully green state, and the drift rows above are complete rather than partial. The
SR code path is not left unmeasured — the scored
granite5_sr/granite5_sr_hfpair below covers it on both lines.
GPU legs are dispatched with
no_cache=true— the test pod pinsvllm/vllm-openai:latest, so a cached pass would answer "what did this commitdo" rather than "is this still true today".
A green run is not proof a gate ran. pytest exits 0 when every test skips,
and the pod then prints
ALL GPU TESTS PASSED.gpu_dispatch.shfails a greenrun whose log has no
N passed, so the only ✅ in this table is one a real testearned; a vacuous leg gets
—plus a note.How each column got its own evidence. The GPU legs are not written in
gpu-tests.yaml:/opt/gsw/derive_legs.py(baked into the runner image, noteditable from this repo) reads the tested commit's
pyproject.tomland emitsat most two — one from
dev(the floor) and one fromdev-latest. So ratherthan widening a matrix, which this repo cannot do, the PR walks
dev-latestupone line per commit and dispatches at each sha:
dev-latestd08fa78eb83c3310ba7ae2040e81806acedc841131The alias is walked three times because each round of fixes landed after the
preceding walk, and
derive_legs.pyreads the tested commit: a sha can onlyevidence the tree as it stood at that sha. Amending an already-dispatched,
already-linked commit is not an option, so the alias walks again and the earlier
shas are retained as history rather than cited as proof. Only
806acedandc841131contain every fix in this PR, so only their runs fill the table.Workflow concurrency is keyed on
inputs.sha, so those runs are parallel ratherthan sequential, and the floor is re-tested by every one of them. The leg label
in each result is the receipt: two runs reporting the same label would mean the
shas were wrong, not that the lines agree.
The final commit leaves
dev-lateston the new ceiling, which is the rightsteady state and the only mergeable one — breakage lands at the ends of the range
(an API deleted upstream fails at the ceiling, one that does not exist yet fails
at the floor), and
plumb_pyproject.py --check --finalrefuses the branch whilethe alias is parked lower. CI re-checks the ends on every future run; the table
above is what proves the middle.
Scored accuracy evals
3565 answerability rows per config on the Vela cluster, against fixed per-config
accuracy bars and tolerances (held with the job definitions in the internal
repo's
upgrade-vllmskill; each config's bar and tolerance are quoted in thetable below, so the numbers here are self-contained). These answer a question the
drift evals cannot: did the model get worse. A model that answers confidently
and wrongly has a perfectly normal top-k distribution.
Reported per added line, against the same unchanged bars — so a reviewer can
see that 0.32 specifically did not regress, rather than that "the evals passed".
granite42_densegranite5_loragranite5_srgranite5_sr_hfAll eight jobs returned MATCH, so there is no drift for the author to accept.
Largest deviation anywhere is 0.0024, within tolerance on every row.
The two columns being identical is the result, not a copy-paste. Each line's
four jobs ran in their own pods (
itzikv-eval-v031-…/itzikv-eval-v032-…,with
paths.resultscarrying the line so neither set can overwrite the other),and each pod logs a vLLM line gate that was read as the receipt: the
v031podsreport
vllm-0.31.0under=== vLLM line gate: expect 0.31.x ===, thev032pods
vllm-0.32.0underexpect 0.32.x. Two different wheels scoring the same3565 rows identically is the expected outcome when neither line changes the
numerics — and worth saying out loud, because identical figures across columns
are otherwise indistinguishable from one line's results reported twice.
Tolerance is ±0.006 for the vLLM configs, which absorbs the HF→vLLM gap for bars
measured on the HF path, and ±0.002 for
granite5_sr_hf, whose bar is the HFnumber so there is no gap to allow for.
granite5_lorahas a second bar at 0.9168 (the switch-HF number). A move towardit is convergence on HF, not a regression — compare against both before calling
drift.
granite5_srandgranite5_sr_hfrun the same checkpoint through the twobackends, so the pair isolates the backend as the only variable;
granite5_sr_hfis also the only scored cover for
hf/modeling_granite_switch.py, which a breakconfined to the HF path could otherwise hide behind three green vLLM legs.
Drift eval baselines
Baselines in
tests/eval/baselines/were recorded on vLLM 0.30 (the previouslysupported ceiling), on the GPU runner itself so the environment that checks
them can reproduce them, and are unchanged by this PR. Re-recording them to
make a line pass would delete the gate, so the numbers below are measured against
the 0.30 baselines exactly as committed.
granite42_vllmgranite5_vllmgranite42_hfThe right-hand column is why these pass comfortably rather than narrowly. The
adapter effect is the distribution movement the adapter itself produces — the
signal this gate exists to protect — and the observed cross-line movement is
two to three orders of magnitude smaller.
granite42_hfis bit-identical onboth lines, which is the expected result for a path that does not go through
vLLM at all and is a useful control on the harness itself.
The measurement is teacher-forced on the baseline's recorded continuation and
sweeps top-k agreement only at k < TOP_K, so a reported mean JSD is distribution
movement at fixed contexts — not a greedy trajectory that desynchronized, and not
churn at the edge of the capture window. Both of those produced false positives
before the gate was corrected; see
tests/eval/_eval_core.py.Summary of evidence
full/audio/eval× (floor + new line) × twoper-line commits. Both
fulllegs were red before the cascade fix and aregreen after it, with the floor leg green throughout — which is what shows the
capability gate reads the API shape in both directions rather than just
silencing 0.32.
0.002138 against an adapter effect of ≈0.22.
src/. Both fixes gate on shape:the processor hook's first parameter name, and
FlashAttentionMetadata.__dataclass_fields__.plumb_pyproject.py --check --finalpasses, i.e.dev-latestis on 0.32, thetrue ceiling — the only state in which merging does not leave the top
supported line untested by CI.
Review notes
Two rows are deliberately not ticked and are worth attention rather than a
silent pass:
—²).derive_legs.pyemits at most two legs percommit, and every commit here spends them on the floor and on a new line. 0.30
stays declared and supported, and is bracketed by two tested lines, but this PR
does not re-prove it.
sr_vllmdrift (—⁷). No compose recipe, since the SR adapters are notpublic. This is the one genuine coverage hole on the vLLM side of the drift
tier — though note the scored eval
granite5_srdoes cover SR onswitch-vllmand returns MATCH, so the SR path is not unevidenced overall.And two rows are ticked identically across all four columns on purpose, not by
inference: pre-commit (¹) and the CPU matrix (³) both install no vLLM, so there
is one measurement and it applies to every line equally. Per-line CPU evidence
comes from the GPU
fulllegs, which do install a wheel each.Prepared with AI assistance (Claude Code). Reviewed by the PR author before
submission.