Skip to content

Commit d1e4c1a

Browse files
author
scott
committed
fix: replace naive repetition detector with char-trigram cosine similarity
Splits recent output into two halves and computes character-trigram cosine. Cosine > 0.85 between halves = repetitive content. Catches both exact repetition and semantic paraphrasing loops where the model rephrases the same idea with slight variation. Replaces the exact N-gram token match which missed semantic repetition (e.g. qwen3:8b's self-doubt loops).
1 parent e54ed1a commit d1e4c1a

1 file changed

Lines changed: 92 additions & 24 deletions

File tree

src/engine/llama.rs

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,56 @@
11
#![allow(clippy::too_many_arguments)]
22
use anyhow::Result;
33
use async_trait::async_trait;
4+
use std::collections::HashMap;
45

56
use super::{GenOptions, InferenceEngine, LoadedModel, ModelSpec};
67

8+
/// Cosine similarity between two strings based on character trigram frequencies.
9+
///
10+
/// Returns a value in [0.0, 1.0]. High similarity (>0.85) between consecutive
11+
/// output chunks indicates the model is generating repetitive content — the
12+
/// "contraction" phase of a semantic spring where reasoning excursions keep
13+
/// returning to the same location in meaning-space.
14+
fn trigram_cosine(a: &str, b: &str) -> f64 {
15+
fn trigrams(s: &str) -> HashMap<&str, f64> {
16+
let mut counts: HashMap<&str, f64> = HashMap::new();
17+
let bytes = s.as_bytes();
18+
if bytes.len() < 3 {
19+
return counts;
20+
}
21+
// Use byte-level trigrams for speed; works for ASCII-heavy LLM output
22+
for i in 0..bytes.len() - 2 {
23+
if let Some(tri) = s.get(i..i + 3) {
24+
*counts.entry(tri).or_default() += 1.0;
25+
}
26+
}
27+
counts
28+
}
29+
let fa = trigrams(a);
30+
let fb = trigrams(b);
31+
if fa.is_empty() || fb.is_empty() {
32+
return 0.0;
33+
}
34+
let mut dot = 0.0f64;
35+
let mut norm_a = 0.0f64;
36+
let mut norm_b = 0.0f64;
37+
for (k, va) in &fa {
38+
norm_a += va * va;
39+
if let Some(vb) = fb.get(k) {
40+
dot += va * vb;
41+
}
42+
}
43+
for vb in fb.values() {
44+
norm_b += vb * vb;
45+
}
46+
let denom = norm_a.sqrt() * norm_b.sqrt();
47+
if denom < 1e-10 {
48+
0.0
49+
} else {
50+
dot / denom
51+
}
52+
}
53+
754
/// Smart thread detection optimized for inference performance
855
/// Matches Ollama's approach: use physical cores with intelligent limits
956
#[allow(dead_code)]
@@ -525,14 +572,26 @@ impl LoadedModel for LlamaLoaded {
525572
let evict_trigger = n_ctx * 3 / 4;
526573
let evict_count = n_ctx / 4;
527574

528-
// Repetition detection: track recent tokens and stop when the model
529-
// enters a degenerate loop (same N-gram repeating). This prevents
530-
// wasting minutes of compute on output that will never reach a
531-
// conclusion. The check runs every 128 tokens for efficiency.
575+
// Repetition detection via character-trigram cosine similarity.
576+
//
577+
// Every CHECK_INTERVAL tokens, split the last WINDOW_CHARS of output
578+
// into two halves and compute character-trigram overlap. If the
579+
// halves are very similar (cosine > SIMILARITY_THRESHOLD), the model
580+
// is generating repetitive content.
581+
//
582+
// This catches both exact repetition (same tokens) and semantic
583+
// repetition (same meaning, different words) because phrase-level
584+
// paraphrases share most character trigrams.
585+
//
586+
// The expected signal for degenerate loops is a "spring" pattern:
587+
// the model makes an excursion (explores a reasoning path) then
588+
// contracts back to the same semantic location. High trigram
589+
// similarity between consecutive windows is the contraction signal.
532590
let mut repeat_check_counter: usize = 0;
533-
const REPEAT_CHECK_INTERVAL: usize = 128;
534-
const REPEAT_WINDOW: usize = 256; // look at last 256 tokens
535-
const REPEAT_THRESHOLD: usize = 4; // same 8-token sequence 4 times = loop
591+
const CHECK_INTERVAL: usize = 64;
592+
const WINDOW_CHARS: usize = 600; // last 600 chars, split into 2×300
593+
const SIMILARITY_THRESHOLD: f64 = 0.85; // cosine > 0.85 = repetitive
594+
const MIN_OUTPUT_FOR_CHECK: usize = 800; // don't check until enough output
536595

537596
for _ in 0..opts.max_tokens {
538597
// Sample from the last (and only) position with logits
@@ -578,26 +637,35 @@ impl LoadedModel for LlamaLoaded {
578637
cb(piece.clone());
579638
}
580639

581-
// Repetition detection: check every REPEAT_CHECK_INTERVAL tokens
582-
// whether the last REPEAT_WINDOW tokens contain a repeated N-gram.
640+
// Repetition detection via character-trigram cosine
583641
repeat_check_counter += 1;
584-
if repeat_check_counter >= REPEAT_CHECK_INTERVAL && all_tokens.len() > REPEAT_WINDOW {
642+
if repeat_check_counter >= CHECK_INTERVAL && out.len() >= MIN_OUTPUT_FOR_CHECK {
585643
repeat_check_counter = 0;
586-
let window = &all_tokens[all_tokens.len() - REPEAT_WINDOW..];
587-
// Check for 8-token N-gram repeating REPEAT_THRESHOLD times
588-
let ngram_len = 8;
589-
if window.len() >= ngram_len * REPEAT_THRESHOLD {
590-
let target = &window[window.len() - ngram_len..];
591-
let mut count = 0usize;
592-
for chunk in window.windows(ngram_len) {
593-
if chunk == target {
594-
count += 1;
595-
}
596-
}
597-
if count >= REPEAT_THRESHOLD {
644+
645+
// Take the last WINDOW_CHARS of output, split into two halves
646+
let window_start = out.len().saturating_sub(WINDOW_CHARS);
647+
// Walk forward to a char boundary
648+
let window_start = out[window_start..].char_indices()
649+
.next()
650+
.map(|(i, _)| window_start + i)
651+
.unwrap_or(window_start);
652+
let window = &out[window_start..];
653+
let mid = window.len() / 2;
654+
// Find char boundary near midpoint
655+
let mid = window[..mid].char_indices()
656+
.next_back()
657+
.map(|(i, _)| i + 1)
658+
.unwrap_or(mid);
659+
let half_a = &window[..mid];
660+
let half_b = &window[mid..];
661+
662+
if !half_a.is_empty() && !half_b.is_empty() {
663+
let sim = trigram_cosine(half_a, half_b);
664+
if sim > SIMILARITY_THRESHOLD {
598665
tracing::warn!(
599-
"Repetition detected: {}-token N-gram repeated {} times in last {} tokens — stopping generation ({} tokens total)",
600-
ngram_len, count, REPEAT_WINDOW, all_tokens.len()
666+
"Repetition detected: trigram cosine={:.3} between output halves \
667+
(threshold={:.2}) — stopping generation ({} chars, {} tokens)",
668+
sim, SIMILARITY_THRESHOLD, out.len(), all_tokens.len()
601669
);
602670
break;
603671
}

0 commit comments

Comments
 (0)