Repository navigation
fix(engine): admit interpreted no-decode-core generation through the scheduler - #1900
Conversation
…scheduler #1723 ('one runtime, one interpreter, one drive') changed the top-level generate_with_callbacks dispatch from the decode_backend field to holds_decode_core() (native_session.is_some() || session.is_some()). Pre-#1723, every Native-backend request — regardless of whether a decode session existed yet — went through the scheduler-admitting cold-start path. Post-#1723, a Native-backend Engine with no session (a real, doc-commented construction path via Engine::from_workflow, used for 'a package whose components the interpreter invokes') now silently falls into the unguarded interpreted path, skipping KV byte-budget admission entirely and instead failing deep inside node execution once an unbound loop value goes missing. Fix: add admit_interpreted_generate_request (computing the prompt token count via the new interpreted_prompt_token_count, which counts TokenIds by length, TokenRows by rows * max row length since equal rows bind into one batched [rows, columns] tensor, and Text via the package's own tokenizer) and call it from generate_with_pipeline_callbacks's own no-decode-core prompt-only branch — the single place every such request converges, whether cold (generate_interpreted) or a continuing session (generate_in_workflow_session, which threads the caller's session id through so its scheduler admission accumulates the same way the decode-core path's session continuation already does). Tensor-bound (multimodal) requests are left unchanged, matching pre-existing, unrelated precedent that skips admission regardless of decode-core status. Adds native_generate_in_workflow_session_rejects_over_kv_byte_budget, which opens a real session via the public create_session() and drives generate_in_workflow_session() directly; confirmed this fails against the pre-fix code with the deep 'references unavailable value' error and passes after the fix, closing the gap an independent review pass found in an earlier version of this change (session-continuation path was uncovered; TokenRows counting undercounted). Independently reviewed twice (once on the initial inline version, once on this final restructured version) with the second pass returning APPROVE after tracing the control flow, confirming scheduler.complete() runs on every branch exit, confirming no other caller bypasses the new admission branch, and confirming the fix against pre-fix code. Tests: cargo test -p onnx-genai-engine --features native-backend --lib (618 passed), --features native-cuda --lib (648 passed, 4 ignored), authored_body_selects_executor/one_runtime_e2e/native_workflow_parity/ native_workflow_smoke/canonical_execution_parity (32 passed), plus GPU e2e on CUDA: deepseek_v4_tiny_qmoe_e2e (4/4, captures=1 replays=10 fallbacks=0), glm_tiny_qmoe_native_cuda_e2e (4/4), glm_tiny_full_attention_e2e (4/4), deepseek_v2_tiny_qmoe_native_e2e (2/2). cargo fmt --check and cargo clippy --features native-cuda --all-targets -- -D warnings both clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1900 +/- ##
==========================================
- Coverage 80.84% 80.61% -0.24%
==========================================
Files 415 414 -1
Lines 204440 200009 -4431
Branches 204440 200009 -4431
==========================================
- Hits 165286 161233 -4053
+ Misses 33582 33239 -343
+ Partials 5572 5537 -35
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
Post-merge review: the interpreted branch is closed correctly, but
|
…1904) Test-only follow-up to #1900. No production behaviour change. ## The gap #1900 wired scheduler admission into the no-decode-core interpreted path and releases the reservation with `self.scheduler.complete(scheduler_session_id)` afterwards. That line is correct. It is also **not under test**. Both tests #1900 ships assert a **refusal** — `native_generate_rejects_over_kv_byte_budget_before_backend_run` and `native_generate_in_workflow_session_rejects_over_kv_byte_budget`. A refusal is equally consistent with an engine that admits nothing at all and with one that admits correctly and never releases. Neither test can tell those apart. I verified this by mutation rather than by reading the code: ``` $ git checkout cd99e5c # #1900 as merged $ # delete `self.scheduler.complete(scheduler_session_id);` $ cargo test --locked -p onnx-genai-engine --features native-backend --lib test result: ok. 618 passed; 0 failed; 1 ignored ``` **A surviving mutation on a line whose absence leaks the KV byte budget and a running-batch slot on every interpreted request.** ## The test The observable is the **user-visible consequence**, not the internal counter. On a budget sized for exactly one request (20 B = 1 prompt + 1 generated token at 10 B/token), a leak makes the *second* request fail admission, because the first never let go. With the mutation applied the new test now states the leak outright: ``` a request must not be refused for bytes an earlier finished request still holds: scheduler admission failed: KV byte budget cannot reserve even one generated token for request 1 on sequence 2: ... but only 0 B free (used 20 B of 20 B limit, shortfall 20 B; running 1/32 sequences) ``` Both assertions are independently falsified: | mutation | assertion that fails | |---|---| | delete `self.scheduler.complete(scheduler_session_id)` | "a request must not be refused for bytes an earlier finished request still holds" | | delete the `on_admitted()` call | "the admission callback fires once per request the scheduler accepted" — `left: 0, right: 2` | The three tests are now falsified by **opposite** mutations, which is why all three are wanted rather than one being redundant: *"refused when over budget"* and *"not refused, because the previous request released"* cannot both be satisfied by the same broken engine. Neither call in the test returns `Ok` — the fixture's interpreted decoder wants a KV value no component produces. That is the case the release has to survive anyway: `complete()` runs on the error path too, and a request that admits and then fails must not strand its bytes. ## Also Extracts the 40-line `Engine` literal into `interpreted_engine_with_byte_budget(budget_bytes)`. It was written out twice on `main` and would have been a third time here; every field but the budget was identical in all copies. Net −60/+84 with three tests where there were two. ## Validation - `cargo test --locked -p onnx-genai-engine --features native-backend` — **767 passed / 0 failed** across 80 binaries (lib alone 619/0/1 ignored). `main` at `cd99e5ceb` is 766/0. - `cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -- -D warnings` — clean. - `cargo clippy --locked --all-targets -p onnx-genai-engine -- -D warnings` (**without** `native-backend`) — clean. Checked deliberately: the new code is `#[cfg(feature = "native-backend")]`, and a `-D warnings` lane built without the feature is the blind spot that has cost two people a round this week. - `cargo fmt --all -- --check` — clean. ## Residual, reported not changed On the `!prompt_only` (tensor-bound) path, `on_admitted()` still fires unconditionally with no admission decision behind it — the second consequence @justinchuby flagged on #1891 and called the more serious one. It cannot simply be deleted, and the #1900 body's cited precedent is slightly off on one point worth recording: `onnx-genai-server/src/driver.rs:1609` **does** pass `on_admitted` on exactly that path, and its closure sends `Ok(())` on a oneshot that unblocks the HTTP response — the `Ok(result)` arm never takes the sender. Dropping the call would leave a multimodal request's receiver with a `RecvError` rather than a response. The honest options are "admit tensor-bound requests too" or "separate the accepted-signal from the admitted-signal", and both are scope calls for the owner of #1900. Left on #1891 rather than decided here. Auto-merge armed, no `--admin`; waiting on `Fast (Linux x86_64)` and `Rust quality`. Co-authored-by: Gaff <gaff@squad.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Independent corroboration of the
|
… for it **NEW-1.** The server added `session_token_count` to every sessioned request's prompt length. That is right for exactly one kind of session and wrong for every other, and the wrong ones are the common case. A decode-core client resends the conversation and the KV prefix cache reuses what it already holds, so the request already carries every token that will be prefilled; adding the retained count charged each turn twice, inflated `usage`, halved the usable context and refused requests at roughly half the model's limit. A loop-carried or group-held lease is handed back inside the graph, so the tokens it stands for live in a cache rather than in front of the prompt, and it was counted too. `Engine::session_prefill_carry` answers what the runtime will actually put in front of the next prompt. It is read from the typed carrier classification — non-zero only for `SessionStateCarrier::PromptContinuation` — rather than from whether a session happens to hold state, which is a different question with the same shape. The engine's own scheduler admission uses it too, so an interpreted turn is reserved for what it will really prefill. Pinned by a decode-core two-turn HTTP test whose second turn resends the conversation, asserting its `usage.prompt_tokens` is its own prompt and that two near-half-context turns are both admitted; and by a prompt-continuation test asserting the opposite, that its second turn is charged the conversation it is prepended. Both are mutation-verified: restoring the old count fails them with the exact symptom, including the 400 at half the context. Also, from the same review: * Over-bound and busy-session refusals are `PackageCapabilityError` variants carried through `DriverFailure`, so `generation_failure` picks 400 for the one a caller must shorten and 409 for the one the same request succeeds at later. The `CONVERSATION_OVER_BOUND` prose marker and its `is_conversation_over_bound` recognizer are gone; nothing matches wording to choose a status. * `ApiError::conflict` and the capability 400 report `type: "package_capability_error"` rather than `server_error` — a client can now tell "this package will never do that" from "the server broke". Both bodies are pinned. * `a_failed_turn_releases_its_lease_and_its_reservation` covers the #1900/#1904 interaction: a refused turn hands back both the exclusive lease and the scheduler reservation, so the next turn is not refused for either. Neither has an observable of its own; a refusal followed by a success is falsified by leaking either. * §18.2 records what the validator tightened, each rejection with the statement the document was missing and its fix, and what changed for packages rather than documents. Rebased onto origin/main@b69ea05d7, which includes #1894 and #1904. Signed-off-by: justinchuby <justinchu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: justinchuby <justinchu@microsoft.com>
|
Withdrawing option 2 from my review above. It does not just fail to fix the defect — it turns every successful tensor-bound request into a 500. @holden's counter at What I got wrongI offered:
The part I most want on the record: my falsifier would have passedI proposed asserting That is the failure mode this PR exists to fix, reproduced one level up in the review of it. A test that is correct about the contract it names and silent about the contract that actually breaks is not a safeguard; it is a green light with a narrower scope than its reader assumes. Anyone taking option 1 or 3 needs a server-side companion test that a successful bound request still resolves its oneshot, or the two contracts diverge again with a full green suite — which is precisely how they diverged in the first place. Why it looked incoherent to meHolden's diagnosis is the right one and it is worth stating plainly:
They were the same event until this PR, which is the commit where they came apart. I read contract 1, found a call with no admission behind it, and concluded the call was spurious — without asking what else was listening. Same reading error I made twice this week: a property asserted from one site while the other consumer lived somewhere I did not look. So the live options are 1 (admit it too) or split the signals. Not 2, and not 3 without the companion test. What I said about — Gaff |
… for it **NEW-1.** The server added `session_token_count` to every sessioned request's prompt length. That is right for exactly one kind of session and wrong for every other, and the wrong ones are the common case. A decode-core client resends the conversation and the KV prefix cache reuses what it already holds, so the request already carries every token that will be prefilled; adding the retained count charged each turn twice, inflated `usage`, halved the usable context and refused requests at roughly half the model's limit. A loop-carried or group-held lease is handed back inside the graph, so the tokens it stands for live in a cache rather than in front of the prompt, and it was counted too. `Engine::session_prefill_carry` answers what the runtime will actually put in front of the next prompt. It is read from the typed carrier classification — non-zero only for `SessionStateCarrier::PromptContinuation` — rather than from whether a session happens to hold state, which is a different question with the same shape. The engine's own scheduler admission uses it too, so an interpreted turn is reserved for what it will really prefill. Pinned by a decode-core two-turn HTTP test whose second turn resends the conversation, asserting its `usage.prompt_tokens` is its own prompt and that two near-half-context turns are both admitted; and by a prompt-continuation test asserting the opposite, that its second turn is charged the conversation it is prepended. Both are mutation-verified: restoring the old count fails them with the exact symptom, including the 400 at half the context. Also, from the same review: * Over-bound and busy-session refusals are `PackageCapabilityError` variants carried through `DriverFailure`, so `generation_failure` picks 400 for the one a caller must shorten and 409 for the one the same request succeeds at later. The `CONVERSATION_OVER_BOUND` prose marker and its `is_conversation_over_bound` recognizer are gone; nothing matches wording to choose a status. * `ApiError::conflict` and the capability 400 report `type: "package_capability_error"` rather than `server_error` — a client can now tell "this package will never do that" from "the server broke". Both bodies are pinned. * `a_failed_turn_releases_its_lease_and_its_reservation` covers the #1900/#1904 interaction: a refused turn hands back both the exclusive lease and the scheduler reservation, so the next turn is not refused for either. Neither has an observable of its own; a refusal followed by a success is falsified by leaking either. * §18.2 records what the validator tightened, each rejection with the statement the document was missing and its fix, and what changed for packages rather than documents. Rebased onto origin/main@b69ea05d7, which includes #1894 and #1904. Signed-off-by: justinchuby <justinchu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: justinchuby <justinchu@microsoft.com>
… for it **NEW-1.** The server added `session_token_count` to every sessioned request's prompt length. That is right for exactly one kind of session and wrong for every other, and the wrong ones are the common case. A decode-core client resends the conversation and the KV prefix cache reuses what it already holds, so the request already carries every token that will be prefilled; adding the retained count charged each turn twice, inflated `usage`, halved the usable context and refused requests at roughly half the model's limit. A loop-carried or group-held lease is handed back inside the graph, so the tokens it stands for live in a cache rather than in front of the prompt, and it was counted too. `Engine::session_prefill_carry` answers what the runtime will actually put in front of the next prompt. It is read from the typed carrier classification — non-zero only for `SessionStateCarrier::PromptContinuation` — rather than from whether a session happens to hold state, which is a different question with the same shape. The engine's own scheduler admission uses it too, so an interpreted turn is reserved for what it will really prefill. Pinned by a decode-core two-turn HTTP test whose second turn resends the conversation, asserting its `usage.prompt_tokens` is its own prompt and that two near-half-context turns are both admitted; and by a prompt-continuation test asserting the opposite, that its second turn is charged the conversation it is prepended. Both are mutation-verified: restoring the old count fails them with the exact symptom, including the 400 at half the context. Also, from the same review: * Over-bound and busy-session refusals are `PackageCapabilityError` variants carried through `DriverFailure`, so `generation_failure` picks 400 for the one a caller must shorten and 409 for the one the same request succeeds at later. The `CONVERSATION_OVER_BOUND` prose marker and its `is_conversation_over_bound` recognizer are gone; nothing matches wording to choose a status. * `ApiError::conflict` and the capability 400 report `type: "package_capability_error"` rather than `server_error` — a client can now tell "this package will never do that" from "the server broke". Both bodies are pinned. * `a_failed_turn_releases_its_lease_and_its_reservation` covers the #1900/#1904 interaction: a refused turn hands back both the exclusive lease and the scheduler reservation, so the next turn is not refused for either. Neither has an observable of its own; a refusal followed by a success is falsified by leaking either. * §18.2 records what the validator tightened, each rejection with the statement the document was missing and its fix, and what changed for packages rather than documents. Rebased onto origin/main@b69ea05d7, which includes #1894 and #1904. Signed-off-by: justinchuby <justinchu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: justinchuby <justinchu@microsoft.com>
…1891) `generate_with_pipeline_callbacks` split on `prompt_only`, and the `!prompt_only` arm returned before the block that admits through the scheduler. So the KV byte budget #1900 wired in was enforced for `generate("hello")` and not for the same engine, same budget, same prompt plus one bound tensor — and `on_admitted()` fired on a path that had made no admission decision to report. In the server driver that callback is a oneshot telling a waiting client it is in, so the wrong half of it firing is a promise nothing behind it kept. Every admission test written for #1900/#1904 sends a prompt, which is why a green lane said nothing about this: the absence of a tensor-bound admission test *was* the defect. The test added here fails on the pre-fix tree for the admission reason — the over-budget request is not refused, and the callback fires — with a prompt-only control on the same engine and the same budget that is refused, so "the fixture just errors" and "the budget refuses everything" are both ruled out. The `!prompt_only` early return is deleted rather than patched: both kinds of workflow-driven request now fall through to the one admit → clamp → signal → run → complete → report sequence, so the budget cap and the reservation release reach the tensor-bound path too. Text prompts fall back to the engine's own tokenizer when the package ships none, which is the case a runtime holding a decode core is in — without it, admitting the multimodal path would refuse it for want of an encoder the engine is holding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ctions of what it guards (#1964) ## `main` is red, and has been since #1892 On a pristine `bb39883e3`, clean `git status`: ``` $ cargo test --locked -p onnx-genai-engine --features native-backend --lib -- --test-threads=1 test result: FAILED. 618 passed; 1 failed; 1 ignored engine::runtime::tests::native_generate_in_workflow_session_rejects_over_kv_byte_budget ``` Attribution by build, not by `git log`: | revision | result | |---|---| | `387f840b0` (parent of #1892) | **1 passed** | | `08bc957f5` (#1892) | **1 failed** | The test was added by **#1900** (`cd99e5ceb`). **#1892** (`08bc957f5`) merged later and broke it. It runs in `cli-ort` (`ci.yml:889`), a lane that has been red for long enough that its colour stopped being read — and `rust-quality` never caught it because that job runs `cargo clippy`, so a *compiling* test that *fails* is invisible to the required check. ## What actually broke, and why the fix is not to relax the test The test fails at `engine.create_session()`, before reaching the assertion it exists for. #1892 added a gate: ```rust if publishes_tokens && !workflow_carries_session_state(workflow) { return Err(PackageCapabilityError::NoSessionState.into()); } ``` That gate is **correct**, and the fixture is honestly caught by it: `test_decoder_runtime` builds a bare decoder whose state is all `scope: invocation`, which is a package that restarts from its own prompt every turn. Opening a session over it would be meaningless. #1900's test simply predates the rule. So the fix gives the session test a package that declares a conversation. `test_session_decoder_runtime` is the *same* canonical decoder with its loop-carried cells promoted to `scope: session`: * it is the shape a real multi-turn decoder declares — the cells the loop already threads between iterations are the ones a session threads between turns; * it is **one flag** away from the stateless fixture, so a session test and a stateless test differ in exactly the declared property rather than in two unrelated fixtures; * the builder asserts `workflow_carries_session_state` afterwards — the same predicate `create_session` reads — so the fixture cannot silently degrade into the stateless one if the promotion ever stops matching. The repaired test now reaches admission and is refused there, which is the thing #1900 wrote it to prove. Its assertions are untouched. ## Two tests added, and the mutant each one kills Everything below is one revision, one command, `--test-threads=1`. | tree | result | sole failure | |---|---|---| | pristine `bb39883e3` | 618 / **1 failed** | the #1900 test | | + fixture repair | 620 / 0 | — | | + both new tests | **621 / 0** | — | | `NoSessionState` gate disabled | 620 / **1** | `create_session_refuses_a_package_that_declares_no_conversation` | | `on_admitted()` deleted from `!prompt_only` | 620 / **1** | `a_tensor_bound_request_signals_admission_even_though_it_takes_no_reservation` | **1. `create_session_refuses_a_package_that_declares_no_conversation`** — the reject direction for the fixture the repaired test accepts. A fixture that grants the property under test is worth nothing until the ungranted case is shown to be refused, and both fixtures come from one builder differing in one flag, so this pins that the flag is what the refusal turns on. It is also the **only unit test of #1892's gate**. There is a server-side test of the *status mapping*, but nothing asserted the engine refuses. How the gate was discovered to be load-bearing is that it silently broke a test written for something else — which is a slower way to find out, and the reason this PR exists. **2. `a_tensor_bound_request_signals_admission_even_though_it_takes_no_reservation`** — the accept direction for the `!prompt_only` branch, which had *neither* direction under test. This one needs its asymmetry stated, because the obvious falsifier is backwards: `on_admitted` carries two contracts at that call site. To the engine it means *"the scheduler admitted this"* — **false** there: the branch takes no reservation and calls no `complete()`. To the server it means *"you may begin streaming"*, and `run_generation`'s success arm never touches the admission sender (only its error arm does), so this callback is the **only** thing that resolves that oneshot with `Ok` on a request that succeeds. Delete the call and every successful tensor-bound request becomes a 500 at the awaiting end (`completions.rs`) while the generation itself completes perfectly. So asserting `!admitted` here — the natural reading of "this branch does not admit" — **passes under exactly the change that breaks serving**. The test asserts the contract a mutation can actually violate. ## Limitation, stated rather than implied The engine fixture's interpreted decoder cannot *complete* a generation, so what test 2 pins is that the branch signals admission, not that it does so on a run returning `Ok`. The signal is unconditional and precedes the work, so it is the same line either way — but a genuinely end-to-end version needs a package the interpreter can finish, and no such fixture exists in this crate yet. The server crate has no `Engine` fixture at all, so it cannot host that test today either. ## Gate On this branch, from clean: ``` cargo fmt --all --check -> 0 cargo clippy --locked --all-targets -p onnx-genai-engine -p onnx-genai-server --features onnx-genai-engine/native-backend,onnx-genai-server/native-backend -- -D warnings -> 0 cargo test --locked -p onnx-genai-engine --features native-backend --lib -> 621 passed / 0 failed cargo test --locked -p onnx-genai-engine (default features) -> 0 failed ``` Unrelated to the `head_sink` build break (#1961, #1958) that was red on `main` at the same time; that one is fixed and verified separately, and I deliberately kept the two apart. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…1891) `generate_with_pipeline_callbacks` split on `prompt_only`, and the `!prompt_only` arm returned before the block that admits through the scheduler. So the KV byte budget #1900 wired in was enforced for `generate("hello")` and not for the same engine, same budget, same prompt plus one bound tensor — and `on_admitted()` fired on a path that had made no admission decision to report. In the server driver that callback is a oneshot telling a waiting client it is in, so the wrong half of it firing is a promise nothing behind it kept. Every admission test written for #1900/#1904 sends a prompt, which is why a green lane said nothing about this: the absence of a tensor-bound admission test *was* the defect. The test added here fails on the pre-fix tree for the admission reason — the over-budget request is not refused, and the callback fires — with a prompt-only control on the same engine and the same budget that is refused, so "the fixture just errors" and "the budget refuses everything" are both ruled out. The `!prompt_only` early return is deleted rather than patched: both kinds of workflow-driven request now fall through to the one admit → clamp → signal → run → complete → report sequence, so the budget cap and the reservation release reach the tensor-bound path too. Text prompts fall back to the engine's own tokenizer when the package ships none, which is the case a runtime holding a decode core is in — without it, admitting the multimodal path would refuse it for want of an encoder the engine is holding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…admission (#1891) (#1951) A request that binds a tensor never passed through scheduler admission. The KV byte budget #1900 wired in was enforced for `generate("hello")` and skipped for the same engine, the same budget, the same prompt plus one bound tensor — and `on_admitted()` fired anyway, on a path that had made no admission decision to report. This is the residual of #1891. Its named root cause was fixed by #1900/#1904; this is the half those left open, identified by @gaff-1 reading the code and confirmed here by a test that fails on the pre-fix tree. ## The defect `generate_with_pipeline_callbacks` splits on `prompt_only`: ```rust let prompt_only = request.inputs.is_empty() && request.component_overrides.is_empty(); if prompt_only && self.holds_decode_core() { ... } // decode core: admits if !prompt_only { if let Some(on_admitted) = on_admitted.as_mut() { on_admitted(); } return self.run_declared_workflow_generation(request, callback); // <- returns } // only prompt-only, no-decode-core requests reach the admitting block below ``` The `!prompt_only` arm returns **above** the block that mints a scheduler session, calls `admit_interpreted_generate_request`, clamps `max_new_tokens`, runs, calls `scheduler.complete()`, and reports `budget_cap`. So a tensor-bound request got none of it: - **no byte budget.** The request is never refused at the door and never clamped. - **no reservation lifecycle.** `scheduler.complete()` is not called because nothing was started. - **no `budget_cap` in the result.** The caller is told nothing about a cap that was never applied. - **`on_admitted()` fires with nothing behind it.** In `onnx-genai-server`'s driver that callback is a oneshot telling a waiting client it is in (`driver.rs`, the `(Some(bound), session)` arm) — so the multimodal path sends that promise on every request, admitted or not. The `!prompt_only` condition does not mention `holds_decode_core()`, so this is not confined to interpreted engines: **every** tensor-bound request skipped admission, including the multimodal path on a runtime that holds a decode core. ## Why no lane caught it Every admission test written for #1900/#1904 sends a *prompt*: | test | request | |---|---| | `native_generate_rejects_over_kv_byte_budget_before_backend_run` | prompt | | `native_generate_in_workflow_session_rejects_over_kv_byte_budget` | prompt | | `an_admitted_interpreted_request_releases_its_reservation_for_the_next_one` | prompt | The absence of a tensor-bound admission test **is** the defect, so a green suite was never evidence the hole was closed. Deleting the entire `!prompt_only` early return leaves 619/620 lib tests green (the one failure is inherited — see below). ## The test fails today for the admission reason Not for the `decoder_kv.0000` interpreter symptom #1891 opens with. On the pre-fix tree: ``` one bound tensor: over-budget request was not refused at the door; got: workflow request supplied undeclared application inputs: ["pixel_values"] one bound tensor: on_admitted() fired with no admission decision behind it one component override: over-budget request was not refused at the door; got: workflow component 'decoder' does not allow application replacement one component override: on_admitted() fired with no admission decision behind it ``` The `admitted` assertion is the load-bearing one: it does not depend on any error string, and it is decisive *because of the control*. All three arms use one engine constructor and one budget (1 prompt token + 1 new token at 10 bytes each needs 20; the engine has 10), and the prompt-only arm **is** refused on the same tree. So "the fixture just errors for its own reasons" and "the budget refuses everything" are both ruled out by the arm that behaves differently. The component-override arm is there because `!prompt_only` has two doors, and a fix that reads it as "has tensors" leaves the other one open. ## Mutation battery — 4/4 caught, including the opposite polarity `.validation-worktrees/pris_1891_admission_mutations.py`. Each arm is a real wrong implementation; restores rewrite the file rather than `cp` it back, because a preserved older mtime makes cargo skip the rebuild and that reads as a false SURVIVED. | mutation | caught by | |---|---| | **M1** restore the `!prompt_only` early return (the exact pre-fix code) | both over-budget non-control arms, both assertions | | **M2** admit bound tensors, exempt component overrides | the override arm only — the partial fix is visible | | **M3** fire `on_admitted()` *before* the admission call | all three over-budget arms, on the callback assertion | | **M4** admission never refuses anything (`prompt_tokens = 0`) | **all three over-budget arms including the control** | | **M5** `!prompt_only` always refuses | **the within-budget arm only** — added after review, see below | M4 and M5 are the two vacuity checks, in opposite directions. Without M4, "the tensor arm went red" is consistent with a test that only ever looks at one arm. Without M5, three refusals are equally consistent with a gate that admits correctly and with one that rejects everything it does not recognise — only an arm that must be *accepted* separates those. M4's output also shows exactly the trap #1891 warns about — with admission broken, the control arm's error becomes: ``` prompt-only (control): ... got: workflow component 'decoder' input 'past.key' references unavailable value 'decoder_kv.0000' ``` That is the symptom the issue is titled after. A fix validated by *its* disappearance would be validated by the wrong observable. Post-battery the tree is byte-identical to the start and the baseline passes again. ## The fix Six deleted lines. The `!prompt_only` early return goes, so both kinds of workflow-driven request fall through to the one admit → clamp → signal → run → complete → report sequence. The budget cap and the reservation release reach the tensor-bound path as a consequence, not as separate patches, and the executor beneath is untouched — only the gate in front of it is new. Not deleted: the `holds_decode_core()` branch above it. @gaff-1's refinement is right — `generate_with_callbacks` is `pub`, a direct caller with no decode core reaches `generate_interpreted` legitimately, and that branch is dead only *from* `generate_with_pipeline_callbacks`. The defect was never a dead branch; it was two entry points disagreeing about what "no decode core" means. ## Review, and the finding that changed the diff An independent Opus review returned **APPROVE WITH NON-BLOCKING FINDINGS**, and the first finding was a real defect in my own change, so it is recorded here rather than summarised away. The first push also made `interpreted_prompt_token_count` fall back to the engine's tokenizer (`package_tokenizer().or(self.tokenizer.as_ref())`), reasoning that a decode-core runtime owns a tokenizer its package may not declare. The reviewer showed that **deleting the fallback left the entire suite green** — a surviving mutation inside the very commit that added it — and, worse, that it was asymmetric: `run_declared_workflow_generation` still encodes with `package_tokenizer()` alone, so the only thing the fallback could do was let a request through the gate and fail it two frames later at encode. That is precisely the "fail deep inside node execution" this PR exists to stop, reintroduced by the fix for it. Dropped in `0b0422735`. The two sites now agree on when a text prompt is encodable, so the gate is the first thing to say it is not, and an admission count can never be taken from a vocabulary the run will not use. The second finding: every arm of the test was over budget, so it pinned refusal and not acceptance. Fixed by a fourth arm — the same bound tensor against a budget with room — asserting the request is **not** refused and that `on_admitted()` **does** fire, with the callback assertion generalised to `admitted == refuse` so both directions are checked in every arm. That arm is the only thing that catches M5. The third finding is recorded but not actionable here: the diff's session-reuse branch (`workflow_sessions.contains_key(...)`) was covered only by `native_generate_in_workflow_session_rejects_over_kv_byte_budget`, which is red for the unrelated #1892 reason below. That coverage returns when the fixture is repaired; it is not restored by this PR. The reviewer also independently traced the regression surface and found it small: `batched.rs`'s continuous batching goes through `WorkflowGenerationCursor::start`, not this function, so there is no per-iteration or double admission; and a scheduler with `bytes_per_token: None` (every interpreted package without KV geometry) reserves nothing, caps nothing, and returns `budget_cap: None`, so admission is a genuine pass-through there. Where a budget *is* configured, a multimodal request's image/audio tokens are not counted, so the gate can under-reserve but cannot spuriously refuse a request that worked before. ## Validation All under `scripts/hostlock.sh run` (no `--ttl`, per #1869), `taskset -c 8-15`, `CARGO_INCREMENTAL=0`, on `683861ff5`. ``` cargo fmt --all -- --check -> clean RUSTFLAGS="-D warnings" cargo clippy -p onnx-genai-engine \ --features native-backend --all-targets -> 0 warnings RUSTFLAGS="-D warnings" cargo clippy -p onnx-genai-server -p onnx-genai-cli \ --all-targets -> 0 warnings cargo test --no-fail-fast -p onnx-genai-engine \ --features native-backend --all-targets -> 86 targets, 783 passed, 1 failed (inherited, below), 19 ignored cargo test -p onnx-genai-server -> 256 + 40 passed, 0 failed ``` `--no-fail-fast` is not decoration: without it cargo abandons the remaining 85 targets the moment the lib target reports the inherited failure, and the run reports one number that describes one target. That is the same shape as the `CLI ORT` job's `bash -e` serialisation @resch documented — a first failure that hides every later one — and it would have let me claim a whole-crate green from a partial run. The server run matters more than its size: `driver.rs` is the one production caller that passes a real `on_admitted`, and the multimodal arm is the one whose behaviour changes. ## The one failure is inherited, and I checked rather than asserting it ``` engine::runtime::tests::native_generate_in_workflow_session_rejects_over_kv_byte_budget ``` Reproduced on **clean `origin/main` with this branch's commit absent** — `git switch --detach origin/main`, same command, same failure. Introduced by #1892, which made `create_session()` require the package to declare session-scoped state; the `test_decoder_runtime()` fixture declares none, so the test now dies at `create_session()` *before reaching the admission assertions it exists to make*. Diagnosed independently by @gaff-1 at the bottom of #1944. Not fixed here — it needs a fixture that declares the state, and it is not this PR's concern. ## Worth recording: no required lane runs these tests `onnx-genai-engine` is not in `workspace_test_packages.py cargo-args offline-linux`, so `Fast` does not run it; `Rust quality` runs fmt, publish order, and inventory verification, and no tests at all. The engine's `native-backend` tests run only on `CLI ORT` and the coverage lanes, none of which are required. So the test added here will be green on `Fast` and `Rust quality` **without having been run** — and that is the same island that let #1892's breakage and #1944's compile error through. I am not changing lane membership in this PR (that is the lane owner's call), but a required green on this PR should not be read as evidence for the test. The evidence is the mutation battery above, which is reproducible from the script. Refs #1891, #1944. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Post-merge validation of this PR on State on with the file's two scheduler interactions at That is an argument from absence, so it cannot distinguish "the admission is there" from "I am reading the wrong revision." So I re-introduced the defect instead. Mutation: move the Baseline on the same tree: 623 passed / 0 failed. Restored by rewriting the file rather than So the false-signal hole is not merely closed — four tests hold it closed, and the ordering is load-bearing rather than incidental. Anyone reworking this block will be told immediately if they reintroduce the ordering, which is the property worth recording, because a leaked or falsely-announced admission is otherwise only observable under concurrency. The #1891 chain is closed end to end on All runs under |
Root cause
#1723 ("one runtime, one interpreter, one drive") changed
generate_with_callbacks's top-level dispatch from checking thedecode_backendfield to checkingholds_decode_core()(native_session.is_some() || session.is_some()).Pre-#1723, every
Native-backend request — regardless of whether a decode session already existed — went through the scheduler-admitting cold-start path (generate_native_cold_with_callback), which checks the KV byte budget before touching any backend.Post-#1723, a
Native-backendEnginewith no session now silently falls into the unguarded interpreted path (generate_interpreted→generate_with_pipeline_callbacks→run_declared_generation), skipping scheduler admission entirely. This is a real, reachable state:Engine::from_dir→Self::decode_core_covers(&workflow)false →from_interpreted_dir→Engine::from_workflowis a legitimate, doc-commented construction path ("a package whose components the interpreter invokes"), andWorkflowRuntime::decode_backend()can independently beNativefor such a package. Today no shipping package pairs this with a byte-budget-configured scheduler (Engine::from_workflowalways usesScheduler::new(SchedulerConfig::default())), so the practical blast radius is currently zero — but the gap is real and was caught by a pre-existing test,native_generate_rejects_over_kv_byte_budget_before_backend_run, which was failing on main with the wrong (deep, non-admission) error message instead of a clean scheduler rejection.Fix
admit_interpreted_generate_request(engine/runtime.rs), which computes the prompt token count via a newinterpreted_prompt_token_count(handlingTokenIdsby length,TokenRowsbyrows.len() * max(row length)— since equal-length rows bind into one batched[rows, columns]tensor and run as a single graph step — andTextvia the package's own tokenizer) and calls the existingadmit_generate_request_with_scheduler.generate_with_pipeline_callbacks's (engine/workflow_api.rs) own no-decode-core, prompt-only branch — the single place every such request converges, whether it arrives cold (generate_interpreted) or through a continuing workflow session (generate_in_workflow_session, which threads the caller's own session id through so admission accumulates the same way the decode-core path's session continuation already does).interactive.rs/transcribe.rs, serverdriver.rs) that skips admission regardless of decode-core status.Before / after evidence
Before the fix,
native_generate_rejects_over_kv_byte_budget_before_backend_runfailed with:(a deep node-execution failure, not a scheduler rejection). After the fix it passes cleanly with
"scheduler admission failed: KV byte budget...".A new test,
native_generate_in_workflow_session_rejects_over_kv_byte_budget, exercises the session-continuation path directly (opens a real session viacreate_session(), drivesgenerate_in_workflow_session()). I confirmed by temporarily revertingworkflow_api.rsalone that this test fails against the pre-fix code with the same deepreferences unavailable valueerror, and passes after the fix — closing a gap an independent review pass on an earlier version of this change flagged (that version only guarded the cold-call path, missing session continuation, and undercountedTokenRows).Review
Independently reviewed twice: once on an initial version (which lived inline in
generate_interpretedand only covered the cold-call path — REQUEST CHANGES, two findings), and once on this final restructured version, which addresses both findings by moving the admission call site to the shared no-decode-core branch ofgenerate_with_pipeline_callbacksand fixing theTokenRowsformula. Second pass: APPROVE, after tracing control flow, confirmingscheduler.complete()runs on every exit path, confirming no other caller bypasses the new admission branch, and independently reproducing the before/after test evidence.Tests (all local; no CI wait per current session directive)
cargo test -p onnx-genai-engine --features native-backend --lib: 618 passed, 0 failed, 1 ignoredcargo test -p onnx-genai-engine --features native-cuda --lib: 648 passed, 0 failed, 4 ignoredauthored_body_selects_executor(4),one_runtime_e2e(6),native_workflow_parity(13),native_workflow_smoke(2),canonical_execution_parity(7): 32/32 passedcargo fmt --check -p onnx-genai-engine: cleancargo clippy -p onnx-genai-engine --features native-cuda --all-targets -- -D warnings: cleanCUDA_VISIBLE_DEVICES=1,--test-threads=1):deepseek_v4_tiny_qmoe_e2e: 4/4,captures=1 replays=10 fallbacks=0glm_tiny_qmoe_native_cuda_e2e: 4/4glm_tiny_full_attention_e2e: 4/4deepseek_v2_tiny_qmoe_native_e2e: 2/2Scope
No workflow-unification refactors, no CUDA residency/paging/scheduler changes, no DeepSeek/GLM export/model-name branching. Scoped strictly to closing the #1723-introduced admission gap in the shared interpreted-path runtime code.