Skip to content

fix(run): --stream sent nothing, --trace-output recorded nothing, and a typo'd --backend ran the default backend - #2427

Closed
noahgift wants to merge 1 commit into
mainfrom
fix/cli-run-stream-trace-flag-validation
Closed

fix(run): --stream sent nothing, --trace-output recorded nothing, and a typo'd --backend ran the default backend#2427
noahgift wants to merge 1 commit into
mainfrom
fix/cli-run-stream-trace-flag-validation

Conversation

@noahgift

Copy link
Copy Markdown
Contributor

Six of the ten apr run findings in #2378, each reproduced against the installed
0.63.0 binary first and verified against a binary built from this branch.

What a user saw

apr run model.gguf "hi" -n 8 --no-gpu --stream on 0.63.0:

{"event":"token","index":0,"token_id":40,"text":""}
{"event":"token","index":1,"token_id":2776,"text":""}
...
{"...,"text":"I'm here to help! How can","tokens":[40,2776,...],"event":"final"}

Eight events, every one of them empty. The ids were right — the final event
decodes them into the whole reply — so a consumer rendering text as events
arrive saw nothing until the run had already finished. Same branch, same command:

{"event":"token","index":0,"token_id":40,"text":"I"}
{"event":"token","index":1,"token_id":2776,"text":"'m"}
{"event":"token","index":2,"token_id":1588,"text":" here"}
{"event":"token","index":3,"token_id":311,"text":" to"}
{"event":"token","index":4,"token_id":1492,"text":" help"}
{"event":"token","index":5,"token_id":0,"text":"!"}
{"event":"token","index":6,"token_id":2585,"text":" How"}
{"event":"token","index":7,"token_id":646,"text":" can"}

--trace-output FILE wrote "events": [] at every documented --trace-level,
and --trace-level chrome wrote its chrome JSON to trace-<epoch>.json in the
CWD while leaving the requested path holding the empty stub. Before / after,
same five levels:

before                        after
none:    events_len= 0        none:    events = 2 ['model_load', 'generate']
basic:   events_len= 0        basic:   events = 2 ['model_load', 'generate']
layer:   events_len= 0        layer:   events = 2 ['model_load', 'generate']
payload: events_len= 0        payload: events = 2 ['model_load', 'generate']
chrome:  events_len= 0        chrome:  chrome traceEvents = 12
trace-1786290823.json         stray trace-*.json in CWD: (none)

--backend banana printed Backend override: banana and ran the default
backend at exit 0 — the precise outcome the --backend cuda guard exists to
prevent, because it makes any throughput measured through that run meaningless.
Now:

run --backend banana   -> rc=2  error: invalid value 'banana' for '--backend <BACKEND>'
                                  [possible values: cuda, cpu, wgpu]
run -f banana          -> rc=2  error: invalid value 'banana' for '--format <FORMAT>'
                                  [possible values: text, json, srt, vtt]
run --trace-level banana -> rc=2 error: invalid value 'banana' for '--trace-level <LEVEL>'
                                  [possible values: none, basic, layer, payload, chrome]
chat --backend banana  -> rc=2  (same)
run --backend cpu      -> rc=0  (unchanged)
run --backend cuda     -> rc=5  (the build-capability guard still fires, unchanged)

Root causes

Finding Cause
1 --stream empty text run_entry.rs wrote a literal "text": "" per event; nothing ever decoded the ids one at a time. Now filled from the model's own tokenizer (single-token decode, the same call the SSE streaming handler makes), resolved once per streamed run and only under --stream.
2 "events": [] + chrome path inference_result.rs:337 ended a format! string with that literal. Rebuilt with serde_json and filled from measured timings (model_load = load_ms, generate = inference_ms). The same format! also interpolated the model path raw into a JSON string, so a path containing " produced non-JSON — fixed by construction. print_chrome_trace now takes the requested path.
3 fabricated layer timings gguf_generate_result.rs:373-380 computed per_token_ms * {0.85, 0.08, 0.02, 0.017} and printed it under a column headed Time. The values stay (they are the only estimate available without the brick profiler) but the table now says ESTIMATED, marks each value ~, prints the share it assumed, and labels TOTAL as wall clock incl. model load and RATE as end-to-end — the 19x contradiction with the [BRICK-PROFILE] block six lines above was two unlabelled numbers.
5 unvalidated flag values --backend, --trace-level, -f were free-form Strings. clap value_parsers on run and chat.
6 false TOKEN_ACCOUNTING warning profiler_contracts.rs:69 compared LmHead.count != tokens_processed. generate_with_cache samples the final token and breaks at tokens.len() >= max_seq_len before feeding it back, so LmHead never fires for it while tokens_processed counts it — the gap was deterministically 1 on every healthy run (10/11, 11/12, 12/13, 16/17, 37/38). Invariant is now "one LmHead per forward pass": tokens_processed or tokens_processed - 1; real miscounting still warns.
7 two misleading strings matmul_fused.rs:501 blamed "a MoE per-expert tensor" for an empty lm_head.weight on a dense tied-embedding Qwen2.5 with no experts at all; it now names both known causes and says to run apr tensors to find the 0-byte tensor. chat.rs:301 printed the literal {stem}.tokenizer.json — an unsubstituted format placeholder inside a plain string — instead of the filename it looked for.

Two existing tests were holding defects in place

  • run_tests_stream_output.rs asserted v["text"].is_string(). That is true of
    "", so the test passed for the entire life of the empty-text bug. It now
    asserts the content, and that concatenating the streamed pieces reproduces the
    final text.
  • run_tests_chrome_trace.rs asserted against a hand-maintained copy of
    print_chrome_trace's body kept in the test file — it could only prove the
    copy agreed with itself. The builder is now extracted (build_chrome_trace)
    and the tests call it.

Mutation check

Each fix reverted with the tests kept, RED verbatim:

trace_output_events_are_populated_from_measured_timings
  trace-output must carry events, got an empty list: {"events":[], ...}

token_accounting_silent_when_last_token_is_never_fed_back
  left: Some("... TOKEN_ACCOUNTING: LmHead.count=10 outside [10, 11] for tokens_processed=11 ...")
  right: None

validate_empty_data_does_not_blame_moe_alone
  a dense tied-embedding model is the other known cause and must be named; got:
  ... likely a MoE per-expert tensor was registered with len-0 data — see aprender#1789

stream_token_events_carry_their_own_decoded_text
  token event 0 must carry decoded text, got: {"event":"token","index":0,"token_id":40,"text":""}

chrome_trace_honours_requested_output_path
  chrome trace must be written to /tmp/apr-chrome-trace-.../requested.json: No such file or directory

layer_trace_marks_derived_timings_as_estimates
  the table must say the per-step values are estimated; got: ... Step  Time  Share ...

test_find_qwen_tokenizer_error_message_content_when_no_cache
no_qwen_tokenizer_message_handles_bare_filename
  1. Pacha cache ({stem}.tokenizer.json alongside model)

Restored: all 8 GREEN. cargo test -p apr-cli --lib 6635 passed,
cargo test -p aprender-serve --lib 15486 passed, cargo fmt --all -- --check
clean, cargo clippy -p apr-cli --lib and -p aprender-serve --lib clean with
-D warnings.

Not fixed here

  • 4 apr run accepts a GGUF whose output_norm.weight is half the declared
    embedding_length. Needs shape validation at model load against the tensor
    contract, not a CLI change.
  • 8, 9 the default wgpu path dequantizes the whole model to F32 (13.9 GB RSS
    for a 1.0 GB Q4_K) before a parity gate that then rejects it at cosine 0.884 on
    this GPU. Backend-ordering and wgpu-kernel work.
  • 10 the --features cuda build's unconditional debug spew (238 eprintln!
    under crates/aprender-serve/src/cuda/) — needs a CUDA build to verify.

Also left alone deliberately: apr serve --backend / --trace-level still take
free-form strings. Same fix applies, different cluster's file.

Refs #2378 (partial) — findings 1, 2, 3, 5, 6, 7 fixed; 4, 8, 9, 10 remain.
Audit epic: #2373

🤖 Generated with Claude Code

… a typo'd --backend ran the default backend

Six defects found by dogfooding apr 0.63.0 from crates.io (audit epic #2373),
all of them things the CLI reported as working while doing nothing.

--stream emitted one NDJSON event per token whose text field was always the
empty string. The ids were right — the terminal final event decoded them into
"I'm here to help! How can" — so a consumer rendering text as events arrive saw
nothing at all until the run finished, which is the entire point of the flag.
Nothing ever decoded the ids one at a time. run_entry.rs now fills each event
from the model's own tokenizer (single-token decode, the same thing the SSE
streaming handler does), resolved once per streamed run and only when --stream
asked for it.

--trace-output FILE wrote "events": [] at every --trace-level, because the
document was built by format! and ended in that literal. The same format! also
interpolated the model path raw into a JSON string, so a path containing a quote
produced a file that is not JSON. inference_result.rs now builds the document
with serde_json and fills events from the timings the run measured (model_load
from load_ms, generate from inference_ms) — nothing synthesised. Separately,
--trace-level chrome ignored --trace-output entirely: it wrote trace-<epoch>.json
into the CWD while the path the user named kept the summary stub, so a scripted
consumer silently read the wrong file. print_chrome_trace now honours the path.

--trace-level layer printed a table headed Time whose values were
wall_ms / tokens * <fixed share> — the same 85/8/2/1.7 split for every model and
every prompt, which is why TOKENIZE, EMBED and DECODE agreed to the hundredth of
a millisecond in every run. It "proved" TRANSFORMER was 85% while the real
[BRICK-PROFILE] block six lines above reported FFN 42% / Qkv 21% / LmHead 4.5%
for the identical run, and printed 1.0 tok/s against the profiler's 19.2 tok/s
with neither figure labelled. The values stay (they are the only estimate
available without the brick profiler) but the table now says ESTIMATED, marks
each value ~, prints the share it assumed, and labels TOTAL as wall clock
including model load and RATE as end-to-end.

--backend, --trace-level and -f accepted any string. --backend banana printed
"Backend override: banana" and quietly ran the default backend — the exact
outcome the --backend cuda guard exists to prevent, since it makes any
throughput measured through the run meaningless. All three now carry clap
value_parsers, on run and on chat.

Every apr run --trace printed
  [CONTRACT WARN] gpu-decode-profiling-v1 TOKEN_ACCOUNTING: LmHead.count=10 !=
  tokens_processed=11
and the gap was always exactly 1 (10/11, 11/12, 12/13, 16/17, 37/38). That is
the generation loop, not a defect: generate_with_cache samples the final token
and breaks at tokens.len() >= max_seq_len before feeding it back, so LmHead
never fires for it while tokens_processed counts it. The invariant is now "one
LmHead per forward pass" — tokens_processed or tokens_processed - 1 — so real
miscounting still warns and healthy runs do not.

Two error strings said things that were not true. An empty lm_head on
qwen2.5-coder-0.5b-instruct.apr, a dense tied-embedding model with no experts at
all, was reported as "likely a MoE per-expert tensor"; the message now names both
known causes and says to run apr tensors to find the 0-byte tensor. apr chat's
missing-tokenizer error printed the literal {stem}.tokenizer.json — an
unsubstituted format placeholder inside a plain string — instead of the filename
it looked for; it now prints concrete paths.

Two existing tests were encoding defects and were fixed:
run_tests_stream_output asserted v["text"].is_string(), which is true of "" and
so passed for the whole life of the empty-text bug, and run_tests_chrome_trace
asserted against a hand-maintained copy of print_chrome_trace's body kept in the
test file, which could only prove the copy agreed with itself.

Mutation-verified: each fix reverted with the tests kept turns its falsifier RED
(8 tests across the two crates), restored turns them GREEN.

Refs #2378 (partial), Audit epic: #2373

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahgift
noahgift enabled auto-merge August 10, 2026 08:33
@noahgift
noahgift marked this pull request as draft August 10, 2026 08:36
auto-merge was automatically disabled August 10, 2026 08:36

Pull request was converted to draft

@noahgift
noahgift marked this pull request as ready for review August 10, 2026 17:15
@noahgift
noahgift enabled auto-merge August 10, 2026 17:15
@noahgift

Copy link
Copy Markdown
Contributor Author

Parking to let the merge queue drain — branch untouched, this will be reopened.

Six PRs are in the merge queue and their merge_group check runs have been starved for hours: 16 runners, and every open PR keeps re-triggering its own workspace-test alongside them. Cancelling those runs does not hold (new ones replace them within a minute) and drafting does not stop CI on this repo, so closing is the only lever that frees the fleet.

The queue is the only path by which anything actually merges, so it gets the runners until it is empty. Reopening immediately afterwards.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant