lean_ctx/core/
marginal_gate.rs1use std::collections::HashSet;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum GateDecision {
13 Pass,
15 Suppress {
17 novelty_ratio: u8,
18 reason: &'static str,
19 },
20}
21
22#[derive(Debug, Clone)]
24pub struct GateConfig {
25 pub novelty_threshold: f64,
27 pub min_novel_tokens: usize,
29}
30
31impl Default for GateConfig {
32 fn default() -> Self {
33 Self {
34 novelty_threshold: 0.20,
35 min_novel_tokens: 50,
36 }
37 }
38}
39
40pub fn check_information_gain(
46 response: &str,
47 already_delivered: &[&str],
48 config: &GateConfig,
49) -> GateDecision {
50 if already_delivered.is_empty() || response.is_empty() {
51 return GateDecision::Pass;
52 }
53
54 let delivered_lines: HashSet<&str> = already_delivered
55 .iter()
56 .flat_map(|chunk| chunk.lines())
57 .map(str::trim)
58 .filter(|l| !l.is_empty())
59 .collect();
60
61 let response_lines: Vec<&str> = response
62 .lines()
63 .map(str::trim)
64 .filter(|l| !l.is_empty())
65 .collect();
66
67 if response_lines.is_empty() {
68 return GateDecision::Pass;
69 }
70
71 let novel_count = response_lines
72 .iter()
73 .filter(|line| !delivered_lines.contains(*line))
74 .count();
75
76 let novelty_ratio = novel_count as f64 / response_lines.len() as f64;
77
78 if novel_count >= config.min_novel_tokens || novelty_ratio >= config.novelty_threshold {
79 return GateDecision::Pass;
80 }
81
82 let ratio_pct = (novelty_ratio * 100.0) as u8;
83 GateDecision::Suppress {
84 novelty_ratio: ratio_pct,
85 reason: "content mostly redundant with previously delivered context",
86 }
87}
88
89pub fn suppression_stub(path: &str, total_lines: usize, novelty_pct: u8) -> String {
91 format!(
92 "[MIG: {path} ({total_lines} lines) — {novelty_pct}% novel, below threshold. \
93 Use ctx_read with lines= for specific sections.]"
94 )
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn pass_when_no_prior_delivery() {
103 let decision = check_information_gain("fn main() {}", &[], &GateConfig::default());
104 assert_eq!(decision, GateDecision::Pass);
105 }
106
107 #[test]
108 fn pass_when_content_is_novel() {
109 let prior = "fn alpha() { 1 }\nfn beta() { 2 }";
110 let response = "fn gamma() { 3 }\nfn delta() { 4 }\nfn epsilon() { 5 }";
111 let decision = check_information_gain(response, &[prior], &GateConfig::default());
112 assert_eq!(decision, GateDecision::Pass);
113 }
114
115 #[test]
116 fn suppress_when_mostly_redundant() {
117 let prior =
118 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10";
119 let response =
120 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10";
121 let config = GateConfig {
122 novelty_threshold: 0.20,
123 min_novel_tokens: 50,
124 };
125 let decision = check_information_gain(response, &[prior], &config);
126 match decision {
127 GateDecision::Suppress { novelty_ratio, .. } => {
128 assert_eq!(novelty_ratio, 0, "0% novel");
129 }
130 GateDecision::Pass => panic!("should have been suppressed"),
131 }
132 }
133
134 #[test]
135 fn pass_when_above_min_novel_tokens() {
136 let prior = "old line";
137 let novel_lines: Vec<String> = (0..60).map(|i| format!("new line {i}")).collect();
138 let response = novel_lines.join("\n");
139 let config = GateConfig {
140 novelty_threshold: 0.99,
141 min_novel_tokens: 50,
142 };
143 let decision = check_information_gain(&response, &[prior], &config);
144 assert_eq!(decision, GateDecision::Pass);
145 }
146
147 #[test]
148 fn suppression_stub_format() {
149 let stub = suppression_stub("src/db.py", 850, 5);
150 assert!(stub.contains("src/db.py"));
151 assert!(stub.contains("5% novel"));
152 assert!(stub.contains("below threshold"));
153 }
154}