use std::collections::HashSet;
use std::hash::{Hash, Hasher};
const SHINGLE_ORDER: usize = 4;
const RECENT_WINDOW: usize = 5;
const MIN_CHANNEL_TOKENS: usize = 8;
const HIGH_SIMILARITY: f64 = 0.65;
const MODERATE_SIMILARITY: f64 = 0.50;
#[derive(Debug, Clone, PartialEq)]
pub enum LoopState {
None,
Hint {
score: f64,
run_length: usize,
channel: SimilarityChannel,
},
Suppress {
score: f64,
run_length: usize,
channel: SimilarityChannel,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimilarityChannel {
Text,
Tools,
Combined,
}
impl SimilarityChannel {
pub fn name(&self) -> &'static str {
match self {
Self::Text => "text",
Self::Tools => "tools",
Self::Combined => "combined",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Signature {
text: HashSet<u64>,
tools: HashSet<u64>,
combined: HashSet<u64>,
}
impl Signature {
pub fn build<'a, I>(text: &str, tool_calls: I) -> Self
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
let text_tokens = tokenise(text);
let mut tool_string = String::new();
for (name, args) in tool_calls {
if !tool_string.is_empty() {
tool_string.push('\n');
}
tool_string.push_str(name);
tool_string.push(' ');
tool_string.push_str(args);
}
let tool_tokens = tokenise(&tool_string);
let text = if text_tokens.len() >= MIN_CHANNEL_TOKENS {
shingles(&text_tokens, SHINGLE_ORDER)
} else {
HashSet::new()
};
let tools = if tool_tokens.len() >= MIN_CHANNEL_TOKENS {
shingles(&tool_tokens, SHINGLE_ORDER)
} else {
HashSet::new()
};
let combined_tokens: Vec<&str> = text_tokens
.iter()
.chain(tool_tokens.iter())
.copied()
.collect();
let combined = if combined_tokens.len() >= MIN_CHANNEL_TOKENS {
shingles(&combined_tokens, SHINGLE_ORDER)
} else {
HashSet::new()
};
Self {
text,
tools,
combined,
}
}
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.tools.is_empty() && self.combined.is_empty()
}
}
pub fn detect(recent_newest_first: &[Signature]) -> LoopState {
let recent: Vec<&Signature> = recent_newest_first
.iter()
.filter(|s| !s.is_empty())
.take(RECENT_WINDOW)
.collect();
if recent.len() < 3 {
return LoopState::None;
}
let channels = [
SimilarityChannel::Combined,
SimilarityChannel::Text,
SimilarityChannel::Tools,
];
let mut best: Option<(SimilarityChannel, f64, usize, bool)> = None;
for ch in channels {
let sims: Vec<f64> = (0..recent.len() - 1)
.map(|i| {
let (a, b) = (recent[i], recent[i + 1]);
let (sa, sb) = match ch {
SimilarityChannel::Text => (&a.text, &b.text),
SimilarityChannel::Tools => (&a.tools, &b.tools),
SimilarityChannel::Combined => (&a.combined, &b.combined),
};
jaccard(sa, sb)
})
.collect();
let mut run_length = 0;
let mut max_score = 0.0_f64;
for &s in &sims {
if s >= MODERATE_SIMILARITY {
run_length += 1;
if s > max_score {
max_score = s;
}
} else {
break;
}
}
if run_length == 0 {
continue;
}
let high_pairs = sims
.iter()
.take(run_length)
.filter(|&&s| s >= HIGH_SIMILARITY)
.count();
let high_band = high_pairs >= 2 || (high_pairs >= 1 && run_length >= 3);
let qualifies = run_length >= 2 || max_score >= HIGH_SIMILARITY;
if !qualifies {
continue;
}
match best {
Some((_, prev_score, prev_run, prev_high)) => {
let prefer_new = run_length > prev_run
|| (run_length == prev_run && max_score > prev_score)
|| (high_band && !prev_high);
if prefer_new {
best = Some((ch, max_score, run_length, high_band));
}
}
None => best = Some((ch, max_score, run_length, high_band)),
}
}
match best {
None => LoopState::None,
Some((channel, score, run_length, high_band)) => {
let turns = run_length + 1;
if high_band || (score >= HIGH_SIMILARITY && turns >= 3) || turns >= 4 {
LoopState::Suppress {
score,
run_length: turns,
channel,
}
} else {
LoopState::Hint {
score,
run_length: turns,
channel,
}
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CallOutcome {
pub call_unit: Option<String>,
pub failing: bool,
pub result_unit: Option<String>,
}
pub fn recent_results_progressing(newest_first: &[CallOutcome], n: usize) -> bool {
if n < 2 || newest_first.len() < n {
return false;
}
let window = &newest_first[..n];
if window.iter().any(|c| c.result_unit.is_none()) {
return false;
}
window.windows(2).any(|pair| {
let a = pair[0].result_unit.as_deref().unwrap_or("");
let b = pair[1].result_unit.as_deref().unwrap_or("");
if a.trim() == b.trim() {
return false;
}
jaccard(&shingle_set(a), &shingle_set(b)) < 0.9
})
}
pub fn detect_exact_failing_repeat(newest_first: &[CallOutcome]) -> Option<usize> {
let newest_unit = newest_first.first().and_then(|c| c.call_unit.as_deref())?;
let run = newest_first
.iter()
.take_while(|c| c.failing && c.call_unit.as_deref() == Some(newest_unit))
.count();
if run >= 3 { Some(run) } else { None }
}
pub fn recent_calls_all_failing(newest_first: &[CallOutcome], n: usize) -> bool {
n >= 1
&& newest_first.len() >= n
&& newest_first[..n]
.iter()
.all(|c| c.call_unit.is_some() && c.failing)
}
pub(crate) fn shingle_set(text: &str) -> HashSet<u64> {
shingles(&tokenise(text), SHINGLE_ORDER)
}
fn tokenise(s: &str) -> Vec<&str> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.collect()
}
fn shingles(tokens: &[&str], order: usize) -> HashSet<u64> {
if tokens.len() < order {
return HashSet::new();
}
let mut out = HashSet::with_capacity(tokens.len());
for window in tokens.windows(order) {
let mut h = std::collections::hash_map::DefaultHasher::new();
for tok in window {
for ch in tok.chars() {
for lc in ch.to_lowercase() {
lc.hash(&mut h);
}
}
0u8.hash(&mut h);
}
out.insert(h.finish());
}
out
}
pub(crate) fn jaccard(a: &HashSet<u64>, b: &HashSet<u64>) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let intersection = a.intersection(b).count() as f64;
let union = a.union(b).count() as f64;
if union == 0.0 {
0.0
} else {
intersection / union
}
}
#[cfg(test)]
mod tests;