src / detector.ts
/**
* Stream repetition detector.
*
* Inspired by dsh-repetition-guard / dsh-dupguard (DeepSeek Harness plugins) and
* Qwen Code's LoopDetectionService. Watches a token stream and reports as soon as
* the tail degenerates into a repetition loop, so the caller can stop the
* generation instead of burning tokens on "wait wait wait wait ...".
*
* Two complementary checks run on a normalized tail buffer:
*
* 1. Consecutive-unit repetition: the tail ends with the same unit (1 chars to
* maxUnitLength chars) repeated >= threshold times in a row. This catches
* single-char loops (",,,,,,"), word loops ("wait wait wait") and phrase
* loops ("I'm sorry. I'm sorry.") regardless of whitespace between units.
*
* 2. Window chanting: a fixed-size window of the tail occurs >= chantThreshold
* times in the buffer. This catches long sentence-level loops that exceed
* maxUnitLength.
*
* Markdown table separators / horizontal rules are protected by ignoredChars
* (stripped before matching) and empty normalized fragments are skipped, so
* tables and rules never trigger a false positive. Inside fenced code blocks the
* threshold is multiplied by codeBlockMultiplier, because repeated tokens are
* legitimate there (ASCII art, separators, generated data).
*/
export interface DetectorConfig {
/** Consecutive identical units required to trigger. */
threshold: number;
/** Occurrences of the same window required to trigger (chant detection). */
chantThreshold: number;
/** Window size (normalized chars) for chant detection. */
windowSize: number;
/** Longest repeated unit considered by the consecutive-unit check. */
maxUnitLength: number;
/** How much raw tail to keep for inspection. */
tailChars: number;
/** Characters stripped before matching (e.g. markdown table decorations). */
ignoredChars: string;
/** Threshold multiplier applied inside fenced code blocks (>= 1). */
codeBlockMultiplier: number;
}
export interface RepetitionHit {
kind: "consecutive-unit" | "window-chant";
/** The repeated unit or window (normalized, may be truncated for display). */
unit: string;
count: number;
threshold: number;
}
export function defaultDetectorConfig(): DetectorConfig {
return {
threshold: 10,
chantThreshold: 4,
windowSize: 32,
maxUnitLength: 24,
tailChars: 512,
ignoredChars: "-|",
codeBlockMultiplier: 3,
};
}
export class StreamRepetitionDetector {
private rawTail = "";
private normTail = "";
private fenceCount = 0;
private fenceCarry = "";
private cfg: DetectorConfig;
constructor(cfg?: Partial<DetectorConfig>) {
this.cfg = { ...defaultDetectorConfig(), ...cfg };
if (this.cfg.threshold < 2) this.cfg.threshold = 2;
if (this.cfg.chantThreshold < 2) this.cfg.chantThreshold = 2;
if (this.cfg.codeBlockMultiplier < 1) this.cfg.codeBlockMultiplier = 1;
}
/** Effective consecutive-unit threshold at the current code-block state. */
private get effectiveThreshold(): number {
return this.inCodeBlock()
? this.cfg.threshold * this.cfg.codeBlockMultiplier
: this.cfg.threshold;
}
/** Effective chant threshold at the current code-block state. */
private get effectiveChantThreshold(): number {
return this.inCodeBlock()
? this.cfg.chantThreshold * this.cfg.codeBlockMultiplier
: this.cfg.chantThreshold;
}
private inCodeBlock(): boolean {
return this.fenceCount % 2 === 1;
}
private normalize(fragment: string): string {
let out = "";
for (const ch of fragment) {
if (/\s/.test(ch)) continue;
if (this.cfg.ignoredChars.includes(ch)) continue;
out += ch.toLowerCase();
}
return out;
}
/**
* Feed the next stream fragment. Returns a hit when a repetition loop is
* detected, otherwise null. Only the tail of the stream is inspected, so cost
* per fragment is bounded regardless of output length.
*/
push(fragment: string): RepetitionHit | null {
if (fragment.length === 0) return null;
// Track fenced code-block state on the raw stream. Fence markers may span
// fragment boundaries, so scan a carry buffer and consume non-overlapping
// ``` occurrences (overlapping runs would corrupt the parity count).
const scan = this.fenceCarry + fragment;
let consumed = 0;
while (consumed + 3 <= scan.length) {
if (scan[consumed] === "`" && scan[consumed + 1] === "`" && scan[consumed + 2] === "`") {
this.fenceCount++;
consumed += 3;
} else {
consumed++;
}
}
this.fenceCarry = scan.slice(Math.max(0, scan.length - 2));
this.rawTail = (this.rawTail + fragment).slice(-this.cfg.tailChars);
const norm = this.normalize(fragment);
if (norm.length === 0) return null; // only decorations/whitespace; nothing to judge
// Keep a normalized tail big enough for both checks.
const keep = Math.max(this.cfg.tailChars, this.cfg.windowSize * (this.cfg.chantThreshold + 1));
this.normTail = (this.normTail + norm).slice(-keep);
const tail = this.normTail;
// 1. Consecutive-unit repetition.
const threshold = this.effectiveThreshold;
const maxUnit = Math.min(this.cfg.maxUnitLength, Math.floor(tail.length / 2));
for (let unitLen = 1; unitLen <= maxUnit; unitLen++) {
const unit = tail.slice(tail.length - unitLen);
let count = 0;
let idx = tail.length;
while (idx - unitLen >= 0 && tail.slice(idx - unitLen, idx) === unit) {
count++;
idx -= unitLen;
}
if (count >= threshold) {
return {
kind: "consecutive-unit",
unit: unit.slice(0, 40),
count,
threshold,
};
}
}
// 2. Window chanting (long sentence-level loops). Sliding windows with
// step 1 detect loops whose period is not aligned to the window size.
// Overlapping hits only occur on periodic text, which is exactly the
// failure mode we want to catch.
const w = this.cfg.windowSize;
const chantThreshold = this.effectiveChantThreshold;
if (tail.length >= w * chantThreshold) {
const counts = new Map<string, number>();
for (let start = 0; start + w <= tail.length; start++) {
const key = tail.slice(start, start + w);
const c = (counts.get(key) ?? 0) + 1;
if (c >= chantThreshold) {
return {
kind: "window-chant",
unit: key.slice(0, 40),
count: c,
threshold: chantThreshold,
};
}
counts.set(key, c);
}
}
return null;
}
/** Reset all state (e.g. between predictions). */
reset(): void {
this.rawTail = "";
this.normTail = "";
this.fenceCount = 0;
this.fenceCarry = "";
}
}
src / detector.ts
/**
* Stream repetition detector.
*
* Inspired by dsh-repetition-guard / dsh-dupguard (DeepSeek Harness plugins) and
* Qwen Code's LoopDetectionService. Watches a token stream and reports as soon as
* the tail degenerates into a repetition loop, so the caller can stop the
* generation instead of burning tokens on "wait wait wait wait ...".
*
* Two complementary checks run on a normalized tail buffer:
*
* 1. Consecutive-unit repetition: the tail ends with the same unit (1 chars to
* maxUnitLength chars) repeated >= threshold times in a row. This catches
* single-char loops (",,,,,,"), word loops ("wait wait wait") and phrase
* loops ("I'm sorry. I'm sorry.") regardless of whitespace between units.
*
* 2. Window chanting: a fixed-size window of the tail occurs >= chantThreshold
* times in the buffer. This catches long sentence-level loops that exceed
* maxUnitLength.
*
* Markdown table separators / horizontal rules are protected by ignoredChars
* (stripped before matching) and empty normalized fragments are skipped, so
* tables and rules never trigger a false positive. Inside fenced code blocks the
* threshold is multiplied by codeBlockMultiplier, because repeated tokens are
* legitimate there (ASCII art, separators, generated data).
*/
export interface DetectorConfig {
/** Consecutive identical units required to trigger. */
threshold: number;
/** Occurrences of the same window required to trigger (chant detection). */
chantThreshold: number;
/** Window size (normalized chars) for chant detection. */
windowSize: number;
/** Longest repeated unit considered by the consecutive-unit check. */
maxUnitLength: number;
/** How much raw tail to keep for inspection. */
tailChars: number;
/** Characters stripped before matching (e.g. markdown table decorations). */
ignoredChars: string;
/** Threshold multiplier applied inside fenced code blocks (>= 1). */
codeBlockMultiplier: number;
}
export interface RepetitionHit {
kind: "consecutive-unit" | "window-chant";
/** The repeated unit or window (normalized, may be truncated for display). */
unit: string;
count: number;
threshold: number;
}
export function defaultDetectorConfig(): DetectorConfig {
return {
threshold: 10,
chantThreshold: 4,
windowSize: 32,
maxUnitLength: 24,
tailChars: 512,
ignoredChars: "-|",
codeBlockMultiplier: 3,
};
}
export class StreamRepetitionDetector {
private rawTail = "";
private normTail = "";
private fenceCount = 0;
private fenceCarry = "";
private cfg: DetectorConfig;
constructor(cfg?: Partial<DetectorConfig>) {
this.cfg = { ...defaultDetectorConfig(), ...cfg };
if (this.cfg.threshold < 2) this.cfg.threshold = 2;
if (this.cfg.chantThreshold < 2) this.cfg.chantThreshold = 2;
if (this.cfg.codeBlockMultiplier < 1) this.cfg.codeBlockMultiplier = 1;
}
/** Effective consecutive-unit threshold at the current code-block state. */
private get effectiveThreshold(): number {
return this.inCodeBlock()
? this.cfg.threshold * this.cfg.codeBlockMultiplier
: this.cfg.threshold;
}
/** Effective chant threshold at the current code-block state. */
private get effectiveChantThreshold(): number {
return this.inCodeBlock()
? this.cfg.chantThreshold * this.cfg.codeBlockMultiplier
: this.cfg.chantThreshold;
}
private inCodeBlock(): boolean {
return this.fenceCount % 2 === 1;
}
private normalize(fragment: string): string {
let out = "";
for (const ch of fragment) {
if (/\s/.test(ch)) continue;
if (this.cfg.ignoredChars.includes(ch)) continue;
out += ch.toLowerCase();
}
return out;
}
/**
* Feed the next stream fragment. Returns a hit when a repetition loop is
* detected, otherwise null. Only the tail of the stream is inspected, so cost
* per fragment is bounded regardless of output length.
*/
push(fragment: string): RepetitionHit | null {
if (fragment.length === 0) return null;
// Track fenced code-block state on the raw stream. Fence markers may span
// fragment boundaries, so scan a carry buffer and consume non-overlapping
// ``` occurrences (overlapping runs would corrupt the parity count).
const scan = this.fenceCarry + fragment;
let consumed = 0;
while (consumed + 3 <= scan.length) {
if (scan[consumed] === "`" && scan[consumed + 1] === "`" && scan[consumed + 2] === "`") {
this.fenceCount++;
consumed += 3;
} else {
consumed++;
}
}
this.fenceCarry = scan.slice(Math.max(0, scan.length - 2));
this.rawTail = (this.rawTail + fragment).slice(-this.cfg.tailChars);
const norm = this.normalize(fragment);
if (norm.length === 0) return null; // only decorations/whitespace; nothing to judge
// Keep a normalized tail big enough for both checks.
const keep = Math.max(this.cfg.tailChars, this.cfg.windowSize * (this.cfg.chantThreshold + 1));
this.normTail = (this.normTail + norm).slice(-keep);
const tail = this.normTail;
// 1. Consecutive-unit repetition.
const threshold = this.effectiveThreshold;
const maxUnit = Math.min(this.cfg.maxUnitLength, Math.floor(tail.length / 2));
for (let unitLen = 1; unitLen <= maxUnit; unitLen++) {
const unit = tail.slice(tail.length - unitLen);
let count = 0;
let idx = tail.length;
while (idx - unitLen >= 0 && tail.slice(idx - unitLen, idx) === unit) {
count++;
idx -= unitLen;
}
if (count >= threshold) {
return {
kind: "consecutive-unit",
unit: unit.slice(0, 40),
count,
threshold,
};
}
}
// 2. Window chanting (long sentence-level loops). Sliding windows with
// step 1 detect loops whose period is not aligned to the window size.
// Overlapping hits only occur on periodic text, which is exactly the
// failure mode we want to catch.
const w = this.cfg.windowSize;
const chantThreshold = this.effectiveChantThreshold;
if (tail.length >= w * chantThreshold) {
const counts = new Map<string, number>();
for (let start = 0; start + w <= tail.length; start++) {
const key = tail.slice(start, start + w);
const c = (counts.get(key) ?? 0) + 1;
if (c >= chantThreshold) {
return {
kind: "window-chant",
unit: key.slice(0, 40),
count: c,
threshold: chantThreshold,
};
}
counts.set(key, c);
}
}
return null;
}
/** Reset all state (e.g. between predictions). */
reset(): void {
this.rawTail = "";
this.normTail = "";
this.fenceCount = 0;
this.fenceCarry = "";
}
}