fix(llm): stop the repeat guard from cutting off numeric data

The degenerate-stream guard treats a long run of one token, a dominant
token, or a periodic window as token collapse. Numbers repeat that way
by design: an 8x8 zero or identity matrix in LaTeX, a padded buffer, a
sparse 0/1 list. Each of those was stopped mid-answer with 'started
repeating tokens'.

Skip the same-run and dominance checks when the repeated token is a
number, skip the periodic check when the recent window is mostly
numbers, and skip the exact-block check when the block is all numbers.
A run of 2048 numbers in a row still stops, so a model stuck printing
'0 0 0' is still caught. Word loops and mixed word/number loops are
unchanged.

Fixes #5560
This commit is contained in:
harshit-ojha0324
2026-10-06 19:01:12 -04:00
parent ed0c8d877d
commit 84cdd866c5
2 changed files with 54 additions and 4 deletions
+17 -4
View File
@@ -432,12 +432,15 @@ class _DegenerateStreamGuard:
"Summer Summer ..."). This is not a useful response and can burn context, "Summer Summer ..."). This is not a useful response and can burn context,
browser memory, and GPU time. Keep the guard conservative: only fire on long browser memory, and GPU time. Keep the guard conservative: only fire on long
same-token runs or a very dominant repeated token in the recent window. same-token runs or a very dominant repeated token in the recent window.
Repetition made only of numbers is data (a zero or identity matrix, a
padded buffer), so it only stops at a long all-numeric backstop.
""" """
def __init__(self, model: str): def __init__(self, model: str):
self.model = model or "model" self.model = model or "model"
self.last_token = "" self.last_token = ""
self.same_run = 0 self.same_run = 0
self.numeric_run = 0
self.recent_tokens: List[str] = [] self.recent_tokens: List[str] = []
self.periodic_tokens: List[str] = [] self.periodic_tokens: List[str] = []
self.total_chars = 0 self.total_chars = 0
@@ -458,6 +461,7 @@ class _DegenerateStreamGuard:
else: else:
self.last_token = token self.last_token = token
self.same_run = 1 self.same_run = 1
self.numeric_run = self.numeric_run + 1 if token.isdigit() else 0
self.recent_tokens.append(token) self.recent_tokens.append(token)
self.periodic_tokens.append(token) self.periodic_tokens.append(token)
if len(self.recent_tokens) > 96: if len(self.recent_tokens) > 96:
@@ -466,14 +470,20 @@ class _DegenerateStreamGuard:
self.periodic_tokens = self.periodic_tokens[-2048:] self.periodic_tokens = self.periodic_tokens[-2048:]
reason = None reason = None
if self.same_run >= 28 and self.total_chars >= 100: if self.same_run >= 28 and self.total_chars >= 100 and not self.last_token.isdigit():
reason = f"repeated '{self.last_token}' {self.same_run} times" reason = f"repeated '{self.last_token}' {self.same_run} times"
elif len(self.recent_tokens) >= 72: elif len(self.recent_tokens) >= 72:
top = max(set(self.recent_tokens), key=self.recent_tokens.count) top = max(set(self.recent_tokens), key=self.recent_tokens.count)
count = self.recent_tokens.count(top) count = self.recent_tokens.count(top)
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78: if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not top.isdigit():
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens" reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
if not reason and len(self.recent_tokens) >= 48: if (
not reason
and len(self.recent_tokens) >= 48
# A window that is mostly numbers is matrix/table data; its
# periodicity is real. Mixed loops still hit the exact-block check.
and sum(t.isdigit() for t in self.recent_tokens) * 2 < len(self.recent_tokens)
):
# Detect a periodic suffix instead of counting repeated n-grams. # Detect a periodic suffix instead of counting repeated n-grams.
# Reused property names are normal in CSS/SVG/JSON; a collapsed # Reused property names are normal in CSS/SVG/JSON; a collapsed
# generation repeats nearly every token at a short fixed period. # generation repeats nearly every token at a short fixed period.
@@ -510,10 +520,13 @@ class _DegenerateStreamGuard:
period = current_anchor - previous period = current_anchor - previous
if period < 24 or len(history) < period * 2: if period < 24 or len(history) < period * 2:
continue continue
if history[-2 * period:-period] == history[-period:]: if history[-2 * period:-period] == history[-period:] and not all(t.isdigit() for t in history[-period:]):
reason = f"repeated an exact {period}-token block twice" reason = f"repeated an exact {period}-token block twice"
break break
if not reason and self.numeric_run >= 2048:
reason = f"emitted {self.numeric_run} numbers in a row"
if not reason: if not reason:
return None return None
+37
View File
@@ -1091,6 +1091,43 @@ def test_degenerate_stream_guard_allows_long_rows_with_changing_values():
assert guard.check(rows) is None assert guard.check(rows) is None
def _latex_matrix(cell):
rows = (" & ".join(cell(i, j) for j in range(8)) for i in range(8))
return "$$\\begin{pmatrix}\n" + " \\\\\n".join(rows) + "\n\\end{pmatrix}$$"
@pytest.mark.parametrize("text", [
_latex_matrix(lambda i, j: "0"),
_latex_matrix(lambda i, j: "1" if i == j else "0"),
"buf = [" + ", ".join("0" for _ in range(64)) + "]",
])
@pytest.mark.parametrize("chunk", [1, 6, 64])
def test_degenerate_stream_guard_allows_numeric_data(text, chunk):
"""#5560: zero/identity matrices and padded buffers repeat numbers by
design; they are not token collapse."""
guard = llm_core._DegenerateStreamGuard("math-model")
assert all(guard.check(text[i:i + chunk]) is None for i in range(0, len(text), chunk))
def test_degenerate_stream_guard_still_stops_an_endless_number_run():
guard = llm_core._DegenerateStreamGuard("looping-model")
chunk = guard.check("0 " * 3000)
assert chunk is not None
assert "numbers in a row" in chunk
def test_degenerate_stream_guard_still_stops_a_mixed_word_number_loop():
guard = llm_core._DegenerateStreamGuard("looping-model")
chunk = guard.check("row 0 0 0 0 " * 300)
assert chunk is not None
assert "repeated" in chunk
def test_terminal_stream_retries_degenerate_generation_before_emitting(monkeypatch): def test_terminal_stream_retries_degenerate_generation_before_emitting(monkeypatch):
calls = 0 calls = 0
requests = [] requests = []