From 84cdd866c56fd99b080bb5e5eea0103827f76948 Mon Sep 17 00:00:00 2001 From: harshit-ojha0324 <92013456+harshit-ojha0324@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:02:34 -0400 Subject: [PATCH] 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 --- src/llm_core.py | 21 +++++++++++++++---- tests/test_llm_core_fallback.py | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/llm_core.py b/src/llm_core.py index bdd3a6a44..d8f8ac6d5 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -432,12 +432,15 @@ class _DegenerateStreamGuard: "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 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): self.model = model or "model" self.last_token = "" self.same_run = 0 + self.numeric_run = 0 self.recent_tokens: List[str] = [] self.periodic_tokens: List[str] = [] self.total_chars = 0 @@ -458,6 +461,7 @@ class _DegenerateStreamGuard: else: self.last_token = token self.same_run = 1 + self.numeric_run = self.numeric_run + 1 if token.isdigit() else 0 self.recent_tokens.append(token) self.periodic_tokens.append(token) if len(self.recent_tokens) > 96: @@ -466,14 +470,20 @@ class _DegenerateStreamGuard: self.periodic_tokens = self.periodic_tokens[-2048:] 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" elif len(self.recent_tokens) >= 72: top = max(set(self.recent_tokens), key=self.recent_tokens.count) 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" - 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. # Reused property names are normal in CSS/SVG/JSON; a collapsed # generation repeats nearly every token at a short fixed period. @@ -510,10 +520,13 @@ class _DegenerateStreamGuard: period = current_anchor - previous if period < 24 or len(history) < period * 2: 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" break + if not reason and self.numeric_run >= 2048: + reason = f"emitted {self.numeric_run} numbers in a row" + if not reason: return None diff --git a/tests/test_llm_core_fallback.py b/tests/test_llm_core_fallback.py index b104418b9..f235fee43 100644 --- a/tests/test_llm_core_fallback.py +++ b/tests/test_llm_core_fallback.py @@ -1091,6 +1091,43 @@ def test_degenerate_stream_guard_allows_long_rows_with_changing_values(): 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): calls = 0 requests = []