Skip to content

engine(text-generator): trim stop-string suffix at char boundary, not whole token - #296

Open
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/107-stop-string-suffix
Open

engine(text-generator): trim stop-string suffix at char boundary, not whole token#296
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/107-stop-string-suffix

Conversation

@jamesburton

Copy link
Copy Markdown

Summary

Addresses an item from #107. When a stop string spans the boundary inside a generated token, the text generator now trims at the exact character boundary inside that token rather than discarding the whole token.

Test results

  • New: StopSuffixTrimmerTests (140 lines, 3 files changed, 282 insertions).
  • All existing stop-condition tests continue to pass.

Notes for review

Single-commit correctness fix on main. This is the precursor to #121's streaming stop-string fix (which stacks on this branch).

… whole token (#107)

When a `StopStringCondition` matched, `TextGenerator` removed the entire last
token from the generated-id list. That over-trimmed whenever the stop string
was a strict suffix of the last token's decoded text — the headline example
from the issue: last token decodes to `"ld<|im_end|>"`, stop string is
`"<|im_end|>"`, the user saw `"Hello, wor"` instead of `"Hello, world"`.

Introduces `StopSuffixTrimmer` — a static helper that finds the longest
stop-string suffix of the decoded text and trims it at the character (UTF-16)
boundary, defending against splitting a surrogate pair. `CheckStopConditions`
now also reports the matched condition index; when the match was a
`StopStringCondition`, the call sites keep the last token in `generatedIds`
and `BuildResponse` performs the suffix trim on the fully-decoded text.

EOS / max-tokens / other non-string stop conditions keep the original
token-removal semantics (their "token" is conceptually the terminator itself,
not text-bearing).

Regression tests cover the headline strict-suffix case, longest-of-multiple
matches, the no-match passthrough, ignoring non-`StopStringCondition` entries,
and the surrogate-pair safety trim.

Note: this PR focuses on the non-streaming `Generate` paths (prefill +
greedy/spec decode loops returning an `InferenceResponse`). The streaming
`GenerateStream` path keeps the existing token-removal behaviour; applying the
same character-level trim to the streaming SSE output is a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds character-level trimming for stop-string suffix matches so generation preserves token prefixes while still removing matched stop strings from the final decoded text.

Changes:

  • Introduces StopSuffixTrimmer to trim the longest stop-string suffix while preserving valid UTF-16 boundaries.
  • Updates TextGenerator to keep the last token id on stop-string matches and trim the decoded text in BuildResponse.
  • Adds unit tests covering strict-suffix, longest-match, non-match, non-stop-string conditions, and surrogate-pair boundaries.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
tests/DotLLM.Tests.Unit/Engine/Samplers/StopConditions/StopSuffixTrimmerTests.cs Adds regression/unit coverage for suffix trimming behavior and UTF-16 safety.
src/DotLLM.Engine/TextGenerator.cs Keeps last token id for stop-string stops and trims stop-string suffix in the final decoded text.
src/DotLLM.Engine/Samplers/StopConditions/StopSuffixTrimmer.cs Implements longest stop-string suffix detection and safe character-boundary trimming.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/DotLLM.Engine/TextGenerator.cs Outdated
Comment on lines +249 to +256
detok.GetTailView(stopTailSize, stopScratch), out int firstMatchedIdx);
if (stopResult != StopResult.Continue)
{
bool isStopStringMatch = IsStopStringMatch(stopConditions, firstMatchedIdx);
if (stopResult == StopResult.Stop)
generatedIds.RemoveAt(generatedIds.Count - 1);
{
if (!isStopStringMatch)
generatedIds.RemoveAt(generatedIds.Count - 1);
Comment on lines 250 to 261
if (stopResult != StopResult.Continue)
{
bool isStopStringMatch = IsStopStringMatch(stopConditions, firstMatchedIdx);
if (stopResult == StopResult.Stop)
generatedIds.RemoveAt(generatedIds.Count - 1);
{
if (!isStopStringMatch)
generatedIds.RemoveAt(generatedIds.Count - 1);
// Stop-string match: keep the last token in the id list; BuildResponse
// will trim the matched stop-string suffix at the character boundary.
}
else
onTokenGenerated?.Invoke(firstTokenId);

string trimmed = StopSuffixTrimmer.TrimMatchedSuffix(fullText, conditions);

Assert.Same(fullText, trimmed);
…ent (#107)

Address Copilot review.

The keep-last-token decision was derived from the type of the FIRST stop
condition to return Stop, so registration order mattered: an EOS condition
ahead of a StopStringCondition matching the same tail would drop the whole
last token and resurrect the over-trim bug. Replace the matched-index
plumbing with HasStopStringSuffix(decodedTail, conditions), built on
StopSuffixTrimmer.MatchedSuffixLength — the same ordinal EndsWith predicate
StopStringCondition itself uses, evaluated over all conditions. The
out-parameter CheckStopConditions overload and IsStopStringMatch are gone;
the tail view is hoisted into a local at each of the three call sites.

Adds a discriminating regression test with EOS + MaxTokens registered
before the matching stop string.

Also documents onTokenGenerated semantics: it is a token-level progress
hook, the stop-triggering token is deliberately never passed to it (an
Action<int> cannot express "emit part of this token", and passing the id
would leak the stop string), so the callback stream may be a strict prefix
of InferenceResponse.Text.
@jamesburton

Copy link
Copy Markdown
Author

Thanks — all three comments worked through. Pushed as 4abc7fb.

1. Ordering dependence of isStopStringMatch — fixed, good catch

Agreed, and this was a real latent bug: the keep-last-token decision was keyed off the type of the first condition to return Stop, so an EOS condition registered ahead of a StopStringCondition matching the same tail would take the RemoveAt branch and resurrect exactly the over-trim this PR fixes.

Took the suggested route. CheckStopConditions is back to its original signature (the out int matchedIndex overload and IsStopStringMatch are gone), the tail view is hoisted into a local at each of the three call sites, and the decision is now:

private static bool HasStopStringSuffix(ReadOnlySpan<char> decodedTail, List<IStopCondition> conditions)
    => StopSuffixTrimmer.MatchedSuffixLength(decodedTail, conditions) > 0;

This is the same predicate StopStringCondition.ShouldStop uses (ordinal EndsWith over the same tail window, which ComputeStopTailSize already sizes for the longest registered stop string), just evaluated over every condition instead of one. Net effect is also less code than before.

Added a discriminating regression test — MatchedSuffixLength_NonStopStringConditionListedFirst_StillMatches, with EosStopCondition and MaxTokensStopCondition registered before the matching stop string. A list with the stop-string condition first wouldn't discriminate, since both formulations agree there.

2. onTokenGenerated not invoked for the kept token — documented, not changed

The inconsistency is real, but neither offered remedy works here:

  • Invoking the callback for that token is worse than the gap. The signature is Action<int> — a token ID. The consumer can only decode the whole token, so it would render ld<|im_end|>, leaking the stop string into consumer text. That directly contradicts StopStringCondition's contract ("The stop string is excluded from the output"), and turns a missing-ld gap into a wrong-output bug. There is no way for an ID-based callback to say "emit two of this token's twelve characters".
  • Reverting to removing the token in stop-string cases is the bug this PR exists to fix.

So the honest fix is a text-level streaming callback that can emit partial token text, which is a genuine API addition and out of scope here (the same follow-up already noted for GenerateStream). What I've done instead is stop leaving it implicit: the onTokenGenerated doc now states that it is a token-level progress hook, not an output stream; that the stop-triggering token is deliberately never passed to it and why; and that the callback stream may therefore be a strict prefix of InferenceResponse.Text, which is the authoritative output. Happy to open the streaming-callback issue if you'd like it tracked.

3. Assert.Same in TrimMatchedSuffix_NoMatch_ReturnsOriginal — kept, with the comment you suggested

Took the alternative you offered rather than dropping it. Reference identity here isn't incidental: the no-match path is the common case (every EOS and max-tokens stop reaches it), so returning the input instance instead of re-allocating an identical string is part of the contract in a codebase where the hot path is meant to be allocation-free. Added Assert.Equal for the value contract alongside it, and a comment saying that if a refactor breaks the Assert.Same, the trimmer has started allocating and that is what wants revisiting.

Verified: DotLLM.Engine builds clean, and --filter over the stop-condition / stop-string / TextGenerator tests passes 16/16.

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.

2 participants