Scope
Pre-public-release audit response. A manual review surfaced correctness, semantics, and hot-path issues in the orchestration/serving layers (not in the core transformer forward path). Items here were triaged as either future work or as ones that can ship behind a smaller guard in the preceding PR. The greedy-only guard and doc-demotion PR (referenced below) ships first; the items in this issue are the actual implementations.
1. Non-greedy speculative decoding is not distributionally correct
File: src/DotLLM.Engine/SpeculativeDecoder.cs:101,103,199
q (draft probability) is taken from TensorPrimitives.SoftMax on constraint-masked raw logits at line 103, and p (target probability) from raw softmax at line 199. The actual draft token at line 105 is sampled via the full SamplerPipeline (temperature / top-k / top-p / min-p / repetition penalty). That means q/p do not describe the distribution the pipeline actually samples from, so modified rejection sampling does not reproduce the target distribution. Only greedy matches because argmax is invariant to monotonic pruning — and only when repetition penalty is 1.0.
Fix: Compute q and p from the post-transform distribution the SamplerPipeline actually samples from (apply the same chain of transforms to both draft and target logits before the softmax). Add non-greedy unit tests comparing empirical output distribution against the target model across seeds. Once non-greedy is correct, remove the constructor guard and tightened call-site predicate added in the preceding PR.
2. Prompt-cache undersize-but-fits-prompt branch caps generation silently
File: src/DotLLM.Engine/TextGenerator.cs:824
if (entry.KvCache.MaxLength >= requiredSize || entry.KvCache.MaxLength >= promptLen)
return (entry.KvCache, matchedTokens, false);
The second clause accepts any cache that fits the prompt. If a later request has larger max_tokens, decode runs out of cache capacity mid-generation and the response ends early as if the model simply stopped.
Fix: Delete the || entry.KvCache.MaxLength >= promptLen clause. Undersized caches must fall through to reallocation. Regression test: prime the prefix cache with a small max_tokens, then issue a larger-max_tokens request with the same prompt and assert the response reaches max_tokens (or a genuine stop) rather than the cached length.
3. tool_choice is parsed and ignored
File: src/DotLLM.Server/Endpoints/ChatCompletionEndpoint.cs:58
RequestConverter.ParseToolChoice runs, but the parsed value is never used. A client sending tool_choice: "required" or a specific function gets "auto" behavior silently.
Fix (short-term): Until constraint-driven enforcement lands, reject tool_choice values other than "auto" (or unset) with HTTP 400 and an OpenAI-shaped {"error": {"type": "invalid_request_error", ...}} body. Track "wire required / specific-function enforcement via constrained decoding" as a follow-up sub-item of this bullet.
4. Streaming tool-call text leaks as delta.content
File: src/DotLLM.Server/Endpoints/ChatCompletionEndpoint.cs:213-249
Every generated token is streamed as normal delta.content, and tool-call parsing runs only after the stream completes (line 246). Clients see raw <tool_call>… markup mid-stream and only get the reclassified finish_reason: "tool_calls" at the end. Non-streaming path is correct.
Fix: Maintain an incremental parser with a small rolling buffer. Suppress delta.content for tokens inside a detected tool-call region, emit delta.tool_calls fragments per the OpenAI SSE contract as arguments are parsed.
5. Empty-text tokens under-count completion_tokens in SSE
File: src/DotLLM.Server/Endpoints/ChatCompletionEndpoint.cs:213
completionTokens is only incremented when token.Text.Length > 0, but GenerationToken (src/DotLLM.Engine/GenerationToken.cs:7) explicitly allows empty Text for incomplete UTF-8 continuation bytes. Usage is undercounted relative to the non-streaming path.
Fix: Count every yielded GenerationToken in completionTokens. Only gate the SSE delta.content emission on Text.Length > 0.
6. Prefix-cache ownership is wrong on cache miss — leaks on throw
File: src/DotLLM.Engine/TextGenerator.cs:834
A freshly allocated cache is returned with ownsKvCache = false before StoreInPrefixCache (line 874) actually transfers ownership. If generation throws between allocation and store (exception, cancellation), the finally blocks at TextGenerator.cs:387,773 skip disposal and the cache leaks.
Fix: Return ownsKvCache = true from the miss path. Flip to false only after _prefixCache.Store successfully runs (the assignment already exists at line 875). Regression test: cancel decode between allocation and store, assert the cache is disposed.
7. Prefix cache stores non-reusable cache types
Files: src/DotLLM.Engine/TextGenerator.cs:809,874
ResolveKvCache reuses only SimpleKvCache and PagedKvCache; the default branch at line 817 falls through to reallocation. But StoreInPrefixCache at line 874 stores any cache instance — quantized, GPU, custom factories. Those entries will never be reused and still hold RAM/VRAM.
Fix: Gate StoreInPrefixCache on the same type check ResolveKvCache uses (or lift the check to a shared helper / an IKvCache.SupportsPrefixReuse property).
8. StopStringCondition drops the whole last token instead of trimming to the matched suffix
Files: src/DotLLM.Engine/Samplers/StopConditions/StopStringCondition.cs:23 and src/DotLLM.Engine/TextGenerator.cs:129
Stop-string detection happens at token boundaries. When a match is found, the entire last token is removed from the generated output, not just the matched suffix. For BPE tokenizers that regularly merge non-stop-preceding text into the same token as the stop sequence, valid content can be lost. The code comment at line 129 acknowledges this.
Fix: Track token-boundary offsets in the decoded string during generation. On stop-match, compute the byte/char offset of the matched suffix start within the last token and trim exactly to that offset, not the whole token.
9. Full-sequence detokenization on every step — O(n²) — DONE in PR #122
Resolved by IncrementalDetokenizer (sliding window + committed StringBuilder with suffix-based eviction) + IStopCondition.ShouldStop(ReadOnlySpan<char>) overload. Six per-step Decode(generatedIds) sites in TextGenerator replaced; streaming delta is TakeDelta().
10. Generate / GenerateStreamingTokensAsync duplicate ~all decode orchestration
Files: src/DotLLM.Engine/TextGenerator.cs:68 (Generate), src/DotLLM.Engine/TextGenerator.cs:400 (GenerateStreamingTokensAsync)
Both methods re-implement prefill, stop handling, speculative decode, cache storage, and timings independently. Behavioral drift risk is already present (e.g., #5 above exists in streaming but not non-streaming at the server layer because the engine path is forked lower down).
Fix: Extract a single internal core — either a private IAsyncEnumerable<GenerationToken> GenerateCore(...) consumed by both the sync (materialize into InferenceResponse) and async (passthrough) public entry points, or a push-style core that invokes a delegate per token. Prefill, stop handling, spec decode, cache store, timings live in one place.
12. Zero-allocation detokenization via TryDecode(..., Span<char>)
Files: src/DotLLM.Tokenizers/ITokenizer.cs, src/DotLLM.Tokenizers/Bpe/BpeCore.cs:11, src/DotLLM.Tokenizers/Bpe/BpeTokenizer.cs:112, src/DotLLM.Tokenizers/Bpe/Gpt2TiktokenEncoding.cs:213, src/DotLLM.Tokenizers/Bpe/SentencePieceEncoding.cs:89, src/DotLLM.Engine/IncrementalDetokenizer.cs
Follow-up from Gemini's review on PR #122. IncrementalDetokenizer.Append and TryEvictOldest each allocate a string per call via _tokenizer.Decode(window, ...). Amortized O(1) per token today; still two small Gen-0 allocations per decode step (~4K strings on a 2K-token generation). Gate for true zero-allocation detokenization.
Fix: Add a span-writing overload to the tokenizer surface:
// ITokenizer + IBpeEncoding
int TryDecode(ReadOnlySpan<int> tokenIds, bool stripBosSpace, Span<char> destination); // returns charsWritten, or -1 if destination too small
Default interface method forwards to Decode and copies — external implementors unchanged.
Implementations:
Gpt2TiktokenEncoding (~1–2 h): accumulate bytes into existing pooled byte buffer, then Encoding.UTF8.GetChars(ReadOnlySpan<byte>, Span<char>) into destination. Replaces the per-call GetString allocation.
SentencePieceEncoding (~2–4 h): rewrite StringBuilder logic to a span cursor. Per-token write with ▁→space in-place replacement; flush byte-token runs via Encoding.UTF8.GetChars(bytes, dest.Slice(cursor)). BpeCore.FlushByteBuffer gains a span overload.
BpeTokenizer: one-line forward.
Adoption in IncrementalDetokenizer (~2–3 h):
- Replace
string _windowText with rented char[] _windowBuf + int _windowLen (ArrayPool-backed, grow-and-re-rent on overflow).
Append: compute upper-bound destination size (sum of _idToToken[id].Length for window); TryDecode into buffer.
TryEvictOldest: rent a second buffer; compare spans with MemoryExtensions.EndsWith — no string materialization.
- Add
Dispose to release pooled buffers; TextGenerator's outer finally calls it.
Testing: TryDecode equivalence tests on both encodings (TryDecode output == Decode output for matching inputs); existing IncrementalDetokenizerTests exercise the adoption end-to-end.
Total: ~1 engineer-day. Eliminates the 2 string allocations per decode step that remain after PR #122.
11. Hot model swap leaks PagedFactory
File: src/DotLLM.Server/Endpoints/ModelManagementEndpoint.cs:55
ServerState expects to own and dispose PagedFactory (src/DotLLM.Server/ServerState.cs:37,101), but the hot-swap path never transfers newState.PagedFactory into the live ServerState. If the newly loaded model uses paged KV, the shared block pool detaches from lifecycle management and is not disposed on the next swap or shutdown.
Fix: In ModelManagementEndpoint.SwapAsync (around line 55), transfer newState.PagedFactory (and any other lifecycle-owned handles) into the live ServerState as part of the swap, disposing the previous one.
Acceptance Criteria
References
- Preceding PR: greedy-only speculative decoding guard + README/docs honesty pass (the minimal shippable subset of the audit)
docs/SPECULATIVE.md — distributional correctness requirements
docs/SERVER.md, docs/TOOL_CALLING.md — OpenAI compatibility surface and tool-calling semantics
docs/KV_CACHE.md, docs/SCHEDULING.md — prefix cache and paged allocator lifecycle
docs/SAMPLING.md — stop-condition semantics
Scope
Pre-public-release audit response. A manual review surfaced correctness, semantics, and hot-path issues in the orchestration/serving layers (not in the core transformer forward path). Items here were triaged as either future work or as ones that can ship behind a smaller guard in the preceding PR. The greedy-only guard and doc-demotion PR (referenced below) ships first; the items in this issue are the actual implementations.
1. Non-greedy speculative decoding is not distributionally correct
File:
src/DotLLM.Engine/SpeculativeDecoder.cs:101,103,199q(draft probability) is taken fromTensorPrimitives.SoftMaxon constraint-masked raw logits at line 103, andp(target probability) from raw softmax at line 199. The actual draft token at line 105 is sampled via the fullSamplerPipeline(temperature / top-k / top-p / min-p / repetition penalty). That meansq/pdo not describe the distribution the pipeline actually samples from, so modified rejection sampling does not reproduce the target distribution. Only greedy matches because argmax is invariant to monotonic pruning — and only when repetition penalty is 1.0.Fix: Compute
qandpfrom the post-transform distribution theSamplerPipelineactually samples from (apply the same chain of transforms to both draft and target logits before the softmax). Add non-greedy unit tests comparing empirical output distribution against the target model across seeds. Once non-greedy is correct, remove the constructor guard and tightened call-site predicate added in the preceding PR.2. Prompt-cache undersize-but-fits-prompt branch caps generation silently
File:
src/DotLLM.Engine/TextGenerator.cs:824The second clause accepts any cache that fits the prompt. If a later request has larger
max_tokens, decode runs out of cache capacity mid-generation and the response ends early as if the model simply stopped.Fix: Delete the
|| entry.KvCache.MaxLength >= promptLenclause. Undersized caches must fall through to reallocation. Regression test: prime the prefix cache with a smallmax_tokens, then issue a larger-max_tokensrequest with the same prompt and assert the response reachesmax_tokens(or a genuine stop) rather than the cached length.3.
tool_choiceis parsed and ignoredFile:
src/DotLLM.Server/Endpoints/ChatCompletionEndpoint.cs:58RequestConverter.ParseToolChoiceruns, but the parsed value is never used. A client sendingtool_choice: "required"or a specific function gets "auto" behavior silently.Fix (short-term): Until constraint-driven enforcement lands, reject
tool_choicevalues other than"auto"(or unset) with HTTP 400 and an OpenAI-shaped{"error": {"type": "invalid_request_error", ...}}body. Track "wirerequired/ specific-function enforcement via constrained decoding" as a follow-up sub-item of this bullet.4. Streaming tool-call text leaks as
delta.contentFile:
src/DotLLM.Server/Endpoints/ChatCompletionEndpoint.cs:213-249Every generated token is streamed as normal
delta.content, and tool-call parsing runs only after the stream completes (line 246). Clients see raw<tool_call>…markup mid-stream and only get the reclassifiedfinish_reason: "tool_calls"at the end. Non-streaming path is correct.Fix: Maintain an incremental parser with a small rolling buffer. Suppress
delta.contentfor tokens inside a detected tool-call region, emitdelta.tool_callsfragments per the OpenAI SSE contract as arguments are parsed.5. Empty-text tokens under-count
completion_tokensin SSEFile:
src/DotLLM.Server/Endpoints/ChatCompletionEndpoint.cs:213completionTokensis only incremented whentoken.Text.Length > 0, butGenerationToken(src/DotLLM.Engine/GenerationToken.cs:7) explicitly allows emptyTextfor incomplete UTF-8 continuation bytes. Usage is undercounted relative to the non-streaming path.Fix: Count every yielded
GenerationTokenincompletionTokens. Only gate the SSEdelta.contentemission onText.Length > 0.6. Prefix-cache ownership is wrong on cache miss — leaks on throw
File:
src/DotLLM.Engine/TextGenerator.cs:834A freshly allocated cache is returned with
ownsKvCache = falsebeforeStoreInPrefixCache(line 874) actually transfers ownership. If generation throws between allocation and store (exception, cancellation), thefinallyblocks atTextGenerator.cs:387,773skip disposal and the cache leaks.Fix: Return
ownsKvCache = truefrom the miss path. Flip tofalseonly after_prefixCache.Storesuccessfully runs (the assignment already exists at line 875). Regression test: cancel decode between allocation and store, assert the cache is disposed.7. Prefix cache stores non-reusable cache types
Files:
src/DotLLM.Engine/TextGenerator.cs:809,874ResolveKvCachereuses onlySimpleKvCacheandPagedKvCache; the default branch at line 817 falls through to reallocation. ButStoreInPrefixCacheat line 874 stores any cache instance — quantized, GPU, custom factories. Those entries will never be reused and still hold RAM/VRAM.Fix: Gate
StoreInPrefixCacheon the same type checkResolveKvCacheuses (or lift the check to a shared helper / anIKvCache.SupportsPrefixReuseproperty).8.
StopStringConditiondrops the whole last token instead of trimming to the matched suffixFiles:
src/DotLLM.Engine/Samplers/StopConditions/StopStringCondition.cs:23andsrc/DotLLM.Engine/TextGenerator.cs:129Stop-string detection happens at token boundaries. When a match is found, the entire last token is removed from the generated output, not just the matched suffix. For BPE tokenizers that regularly merge non-stop-preceding text into the same token as the stop sequence, valid content can be lost. The code comment at line 129 acknowledges this.
Fix: Track token-boundary offsets in the decoded string during generation. On stop-match, compute the byte/char offset of the matched suffix start within the last token and trim exactly to that offset, not the whole token.
9.
Full-sequence detokenization on every step — O(n²)— DONE in PR #122Resolved by
IncrementalDetokenizer(sliding window + committedStringBuilderwith suffix-based eviction) +IStopCondition.ShouldStop(ReadOnlySpan<char>)overload. Six per-stepDecode(generatedIds)sites inTextGeneratorreplaced; streaming delta isTakeDelta().10.
Generate/GenerateStreamingTokensAsyncduplicate ~all decode orchestrationFiles:
src/DotLLM.Engine/TextGenerator.cs:68(Generate),src/DotLLM.Engine/TextGenerator.cs:400(GenerateStreamingTokensAsync)Both methods re-implement prefill, stop handling, speculative decode, cache storage, and timings independently. Behavioral drift risk is already present (e.g., #5 above exists in streaming but not non-streaming at the server layer because the engine path is forked lower down).
Fix: Extract a single internal core — either a
private IAsyncEnumerable<GenerationToken> GenerateCore(...)consumed by both the sync (materialize intoInferenceResponse) and async (passthrough) public entry points, or a push-style core that invokes a delegate per token. Prefill, stop handling, spec decode, cache store, timings live in one place.12. Zero-allocation detokenization via
TryDecode(..., Span<char>)Files:
src/DotLLM.Tokenizers/ITokenizer.cs,src/DotLLM.Tokenizers/Bpe/BpeCore.cs:11,src/DotLLM.Tokenizers/Bpe/BpeTokenizer.cs:112,src/DotLLM.Tokenizers/Bpe/Gpt2TiktokenEncoding.cs:213,src/DotLLM.Tokenizers/Bpe/SentencePieceEncoding.cs:89,src/DotLLM.Engine/IncrementalDetokenizer.csFollow-up from Gemini's review on PR #122.
IncrementalDetokenizer.AppendandTryEvictOldesteach allocate astringper call via_tokenizer.Decode(window, ...). Amortized O(1) per token today; still two small Gen-0 allocations per decode step (~4K strings on a 2K-token generation). Gate for true zero-allocation detokenization.Fix: Add a span-writing overload to the tokenizer surface:
Default interface method forwards to
Decodeand copies — external implementors unchanged.Implementations:
Gpt2TiktokenEncoding(~1–2 h): accumulate bytes into existing pooled byte buffer, thenEncoding.UTF8.GetChars(ReadOnlySpan<byte>, Span<char>)into destination. Replaces the per-callGetStringallocation.SentencePieceEncoding(~2–4 h): rewrite StringBuilder logic to a span cursor. Per-token write with▁→space in-place replacement; flush byte-token runs viaEncoding.UTF8.GetChars(bytes, dest.Slice(cursor)).BpeCore.FlushByteBuffergains a span overload.BpeTokenizer: one-line forward.Adoption in
IncrementalDetokenizer(~2–3 h):string _windowTextwith rentedchar[] _windowBuf+int _windowLen(ArrayPool-backed, grow-and-re-rent on overflow).Append: compute upper-bound destination size (sum of_idToToken[id].Lengthfor window);TryDecodeinto buffer.TryEvictOldest: rent a second buffer; compare spans withMemoryExtensions.EndsWith— no string materialization.Disposeto release pooled buffers;TextGenerator's outerfinallycalls it.Testing:
TryDecodeequivalence tests on both encodings (TryDecodeoutput ==Decodeoutput for matching inputs); existingIncrementalDetokenizerTestsexercise the adoption end-to-end.Total: ~1 engineer-day. Eliminates the 2 string allocations per decode step that remain after PR #122.
11. Hot model swap leaks
PagedFactoryFile:
src/DotLLM.Server/Endpoints/ModelManagementEndpoint.cs:55ServerStateexpects to own and disposePagedFactory(src/DotLLM.Server/ServerState.cs:37,101), but the hot-swap path never transfersnewState.PagedFactoryinto the liveServerState. If the newly loaded model uses paged KV, the shared block pool detaches from lifecycle management and is not disposed on the next swap or shutdown.Fix: In
ModelManagementEndpoint.SwapAsync(around line 55), transfernewState.PagedFactory(and any other lifecycle-owned handles) into the liveServerStateas part of the swap, disposing the previous one.Acceptance Criteria
max_tokensthan cached capacity produces a full-length responsetool_choicevalues with 400; follow-up sub-issue filed forrequired/ specific-function enforcementdelta.content; tool-call fragments emitted asdelta.tool_callscompletion_tokensmatches non-streaming path on prompts containing multi-byte UTF-8StoreInPrefixCacheonly stores reusable cache types (shared type check withResolveKvCache)StopStringConditiontrims exactly the matched suffix on BPE tokenizers (regression test with a tokenizer that merges adjacent text into the stop token)GenerateandGenerateStreamingTokensAsyncshare a single internal orchestration corePagedFactoryand installs the new one under liveServerStateownershipReferences
docs/SPECULATIVE.md— distributional correctness requirementsdocs/SERVER.md,docs/TOOL_CALLING.md— OpenAI compatibility surface and tool-calling semanticsdocs/KV_CACHE.md,docs/SCHEDULING.md— prefix cache and paged allocator lifecycledocs/SAMPLING.md— stop-condition semantics