|
1 | 1 | #![allow(clippy::too_many_arguments)] |
2 | 2 | use anyhow::Result; |
3 | 3 | use async_trait::async_trait; |
| 4 | +use std::collections::HashMap; |
4 | 5 |
|
5 | 6 | use super::{GenOptions, InferenceEngine, LoadedModel, ModelSpec}; |
6 | 7 |
|
| 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 | + |
7 | 54 | /// Smart thread detection optimized for inference performance |
8 | 55 | /// Matches Ollama's approach: use physical cores with intelligent limits |
9 | 56 | #[allow(dead_code)] |
@@ -525,14 +572,26 @@ impl LoadedModel for LlamaLoaded { |
525 | 572 | let evict_trigger = n_ctx * 3 / 4; |
526 | 573 | let evict_count = n_ctx / 4; |
527 | 574 |
|
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. |
532 | 590 | 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 |
536 | 595 |
|
537 | 596 | for _ in 0..opts.max_tokens { |
538 | 597 | // Sample from the last (and only) position with logits |
@@ -578,26 +637,35 @@ impl LoadedModel for LlamaLoaded { |
578 | 637 | cb(piece.clone()); |
579 | 638 | } |
580 | 639 |
|
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 |
583 | 641 | 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 { |
585 | 643 | 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 { |
598 | 665 | 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() |
601 | 669 | ); |
602 | 670 | break; |
603 | 671 | } |
|
0 commit comments