|
6 | 6 | * invisible to them. This module watches the accumulated text of the current |
7 | 7 | * inference cycle and flags a trailing window that repeats verbatim past a |
8 | 8 | * threshold, so the run loop can abort the cycle instead of streaming forever. |
| 9 | + * |
| 10 | + * Also home to the TUI stall-watchdog's character-level tail-repetition |
| 11 | + * guard (`detectTailCharLoop`) — a separate, simpler check consolidated |
| 12 | + * here from tui/stall-watchdog.ts so the two repetition detectors live in one |
| 13 | + * module instead of two. It solves the same "is the tail looping" question |
| 14 | + * for a different consumer with different constants; see its own doc comment |
| 15 | + * for why it is not merged into `detectRepetition` above. |
9 | 16 | */ |
10 | 17 |
|
| 18 | +import { detectSequencePeriod, type SequencePeriodCheck } from "../util/period-detection.js"; |
| 19 | + |
11 | 20 | /** Tunable thresholds for the trailing-window repetition check. */ |
12 | 21 | export interface RepetitionConfig { |
13 | 22 | /** Smallest normalized window (chars) considered a loop unit. */ |
@@ -245,3 +254,72 @@ export function trackContentlessGrowth( |
245 | 254 | hit: visibleChars < config.minVisibleChars, |
246 | 255 | }; |
247 | 256 | } |
| 257 | + |
| 258 | +// The captured incident looped two sentences with no line break between them |
| 259 | +// ("...ranked findings.Confirming callId emission...") — degeneration is a |
| 260 | +// character-level loop, not a line-level one. Splitting on "\n" misses it |
| 261 | +// entirely, so the tail is treated as a plain string and checked for the |
| 262 | +// smallest period it exactly repeats: the shortest span p such that the last |
| 263 | +// several hundred characters equal p repeated. |
| 264 | +// |
| 265 | +// A period below this is more likely a short structural tic (indentation, a |
| 266 | +// repeated bullet or table-cell divider) than a looping phrase. Live loops |
| 267 | +// repeat units as short as 10 chars ("Groaning. " emitted ~1,363 times), so |
| 268 | +// the floor sits at 8 — short structural tics that survive it (a "- item\n" |
| 269 | +// bullet is 7 chars) fall below, and the ones at or above it are filtered by |
| 270 | +// the distinct-chars floor and the raised repeat bar instead. Still well |
| 271 | +// under the ~140-char period of the captured incident's two-sentence cycle. |
| 272 | +const CHAR_REPETITION_MIN_PERIOD = 8; |
| 273 | +// How many exact repeats of the period are required before it counts as a |
| 274 | +// loop rather than a coincidence. Raised 3x in step with the 3x-lower period |
| 275 | +// floor so the minimum exactly-periodic span stays at 192 chars (was 24*8, |
| 276 | +// now 8*24). Verified against real non-degenerate repetition: a 6-row |
| 277 | +// markdown table separator (period ~51 chars, 6 exact repeats) and 3 |
| 278 | +// identical code lines (period ~60 chars, 3 exact repeats) both land far |
| 279 | +// under this bar and are not flagged; a genuine degenerate loop repeats |
| 280 | +// hundreds of times, so it still clears the bar long before the stream ends. |
| 281 | +const CHAR_REPETITION_MIN_REPEATS = 24; |
| 282 | +// Hard ceiling on the period search regardless of buffer size, purely to cap |
| 283 | +// worst-case work per check — token-level degeneration loops on a phrase or |
| 284 | +// two, never on multi-paragraph spans. |
| 285 | +const CHAR_REPETITION_MAX_PERIOD_CAP = 2_000; |
| 286 | +// A monochrome run ("x".repeat(500), a "----" rule, a wall of spaces) is |
| 287 | +// trivially periodic at *every* period, which would otherwise make it the |
| 288 | +// single easiest thing to false-trigger on — verified by execution against |
| 289 | +// `thinking-reveal.test.ts`'s burst-of-"x" fixture, which tripped the guard |
| 290 | +// before this floor existed. Requiring the repeating unit itself to contain |
| 291 | +// this many distinct characters keeps single-character and low-variety runs |
| 292 | +// out without weakening the sentence-level case: the captured incident's |
| 293 | +// cycle spans two full sentences, comfortably above it. |
| 294 | +const CHAR_REPETITION_MIN_DISTINCT_CHARS = 8; |
| 295 | + |
| 296 | +export type TailCharLoopCheck = SequencePeriodCheck; |
| 297 | + |
| 298 | +/** |
| 299 | + * Whether the tail of `text` is an exact repeat of some short span at least |
| 300 | + * `CHAR_REPETITION_MIN_REPEATS` times. Pure text-in, decision-out: the caller |
| 301 | + * (the TUI stall watchdog) owns accumulating the buffer across deltas and |
| 302 | + * cycles within a turn. |
| 303 | + * |
| 304 | + * Delegates to the generic detectSequencePeriod over the character array — |
| 305 | + * periods longer than `text.length / CHAR_REPETITION_MIN_REPEATS` are skipped |
| 306 | + * there, not as an arbitrary cutoff but because they cannot mathematically |
| 307 | + * reach the occurrence threshold within the given text. |
| 308 | + * |
| 309 | + * This is deliberately not merged with `detectRepetition` above: that one |
| 310 | + * normalizes whitespace/invisibles and optionally folds digits before |
| 311 | + * running a KMP period search tuned for streamed model text, while this is a |
| 312 | + * plain per-character search with a distinct-chars floor instead of digit |
| 313 | + * folding, tuned for the TUI's live character buffer. Same question ("is the |
| 314 | + * tail looping"), different constants and different false-positive shape — |
| 315 | + * see the config comments on each for why neither threshold set may be |
| 316 | + * changed to match the other. |
| 317 | + */ |
| 318 | +export function detectTailCharLoop(text: string): TailCharLoopCheck { |
| 319 | + return detectSequencePeriod(text.split(""), { |
| 320 | + minPeriod: CHAR_REPETITION_MIN_PERIOD, |
| 321 | + maxPeriod: CHAR_REPETITION_MAX_PERIOD_CAP, |
| 322 | + minRepeats: CHAR_REPETITION_MIN_REPEATS, |
| 323 | + minDistinct: () => CHAR_REPETITION_MIN_DISTINCT_CHARS, |
| 324 | + }); |
| 325 | +} |
0 commit comments