use super::ledger::{InjectedChunk, SuppressedRecall, SuppressionReason, TurnLedger};
const CONFIDENCE_GATE: f64 = 0.60;
pub const DEFAULT_DECAY: f64 = 0.85;
fn effective_score(raw: f64, turns_since: usize, decay: f64) -> f64 {
if turns_since == 0 {
return 0.0;
}
raw * decay.powi(i32::try_from(turns_since).unwrap_or(i32::MAX))
}
#[derive(Debug)]
pub struct RecallCandidate {
pub chunk_id: String,
pub path: String,
pub score: f64,
pub title: String,
pub snippet: String,
}
#[derive(Debug)]
pub struct SuppressionResult {
pub injected: Vec<RecallCandidate>,
pub suppressed: Vec<SuppressedRecall>,
}
#[must_use]
pub fn apply_suppression(
candidates: Vec<RecallCandidate>,
ledger: &TurnLedger,
decay: f64,
) -> SuppressionResult {
let mut injected = Vec::new();
let mut suppressed = Vec::new();
for candidate in candidates {
if candidate.score < CONFIDENCE_GATE {
suppressed.push(SuppressedRecall {
chunk_id: candidate.chunk_id,
path: candidate.path,
score: candidate.score,
reason: SuppressionReason::BelowConfidenceGate,
});
continue;
}
if ledger
.turns_since_chunk_last_injected(&candidate.chunk_id)
.is_some_and(|n| effective_score(candidate.score, n, decay) < CONFIDENCE_GATE)
{
suppressed.push(SuppressedRecall {
chunk_id: candidate.chunk_id,
path: candidate.path,
score: candidate.score,
reason: SuppressionReason::SameChunkRecentlyInjected,
});
continue;
}
if ledger
.turns_since_note_last_injected(&candidate.path)
.is_some_and(|n| effective_score(candidate.score, n, decay) < CONFIDENCE_GATE)
{
suppressed.push(SuppressedRecall {
chunk_id: candidate.chunk_id,
path: candidate.path,
score: candidate.score,
reason: SuppressionReason::SameNoteRecentlyInjected,
});
continue;
}
injected.push(candidate);
}
SuppressionResult {
injected,
suppressed,
}
}
#[must_use]
pub fn to_injected_chunk(candidate: &RecallCandidate) -> InjectedChunk {
InjectedChunk {
chunk_id: candidate.chunk_id.clone(),
path: candidate.path.clone(),
score: candidate.score,
}
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_DECAY, RecallCandidate, apply_suppression};
use crate::mcp::session::ledger::{InjectedChunk, SuppressionReason, TurnLedger, TurnRecord};
fn candidate(chunk_id: &str, path: &str, score: f64) -> RecallCandidate {
RecallCandidate {
chunk_id: chunk_id.to_owned(),
path: path.to_owned(),
score,
title: "Test".to_owned(),
snippet: "snippet".to_owned(),
}
}
fn ledger_with_chunk_n_turns_ago(chunk_id: &str, path: &str, n: usize) -> TurnLedger {
let mut ledger = TurnLedger::new();
ledger.record_turn(TurnRecord {
turn_id: "t0".to_owned(),
query_fingerprint: String::new(),
injected: vec![InjectedChunk {
chunk_id: chunk_id.to_owned(),
path: path.to_owned(),
score: 0.9,
}],
suppressed: vec![],
skipped: false,
});
for i in 0..n {
ledger.record_turn(TurnRecord {
turn_id: format!("e{i}"),
query_fingerprint: String::new(),
injected: vec![],
suppressed: vec![],
skipped: false,
});
}
ledger
}
#[test]
fn low_score_chunk_suppressed_by_confidence_gate() {
let ledger = ledger_with_chunk_n_turns_ago("c", "notes/foo.md", 1);
let result = apply_suppression(
vec![candidate("c", "notes/foo.md", 0.65)],
&ledger,
DEFAULT_DECAY,
);
assert_eq!(result.injected.len(), 0);
assert_eq!(
result.suppressed[0].reason,
SuppressionReason::SameChunkRecentlyInjected
);
}
#[test]
fn high_score_chunk_passes_one_turn_ago() {
let ledger = ledger_with_chunk_n_turns_ago("c", "notes/foo.md", 1);
let result = apply_suppression(
vec![candidate("c", "notes/foo.md", 0.85)],
&ledger,
DEFAULT_DECAY,
);
assert_eq!(
result.injected.len(),
1,
"score 0.85 should pass after 1 turn with decay 0.85"
);
}
#[test]
fn moderate_score_suppressed_three_turns_ago() {
let ledger = ledger_with_chunk_n_turns_ago("c", "notes/foo.md", 3);
let result = apply_suppression(
vec![candidate("c", "notes/foo.md", 0.65)],
&ledger,
DEFAULT_DECAY,
);
assert_eq!(result.injected.len(), 0);
}
#[test]
fn high_score_eligible_three_turns_ago() {
let ledger = ledger_with_chunk_n_turns_ago("c", "notes/foo.md", 3);
let result = apply_suppression(
vec![candidate("c", "notes/foo.md", 0.98)],
&ledger,
DEFAULT_DECAY,
);
assert_eq!(
result.injected.len(),
1,
"score 0.98 should re-emerge after 3 turns with gate 0.60"
);
}
#[test]
fn below_confidence_gate_suppressed() {
let result = apply_suppression(
vec![candidate("new", "notes/bar.md", 0.2)],
&TurnLedger::new(),
DEFAULT_DECAY,
);
assert_eq!(result.injected.len(), 0);
assert_eq!(
result.suppressed[0].reason,
SuppressionReason::BelowConfidenceGate
);
}
#[test]
fn novel_chunk_passes_through() {
let result = apply_suppression(
vec![candidate("new", "notes/new.md", 0.85)],
&TurnLedger::new(),
DEFAULT_DECAY,
);
assert_eq!(result.injected.len(), 1);
assert!(result.suppressed.is_empty());
}
#[test]
fn all_suppressed_means_empty_injected() {
let ledger = ledger_with_chunk_n_turns_ago("c", "notes/foo.md", 1);
let result = apply_suppression(
vec![
candidate("c", "notes/foo.md", 0.46),
candidate("d", "notes/foo.md", 0.46),
],
&ledger,
DEFAULT_DECAY,
);
assert_eq!(
result.injected.len(),
0,
"caller must skip injection, not substitute lower-ranked results"
);
}
}