1use std::collections::HashMap;
2
3use crate::tile::{SalienceMap, Tile, TileId};
4
5pub struct TileEncoder {
7 pub max_summary_bytes: usize,
8 pub constraint_extractor: ConstraintExtractor,
9}
10
11impl Default for TileEncoder {
12 fn default() -> Self {
13 Self {
14 max_summary_bytes: 512,
15 constraint_extractor: ConstraintExtractor::default(),
16 }
17 }
18}
19
20impl TileEncoder {
21 pub fn encode(&self, content: &str, salience: &SalienceMap) -> Tile {
23 let summary = self.compress(content);
24 let constraints = if salience.constraints.is_empty() {
25 self.constraint_extractor.extract(content)
26 } else {
27 salience.constraints.clone()
28 };
29 let valence = salience.valence.unwrap_or_else(|| self.constraint_extractor.valence(content));
30
31 Tile {
32 id: TileId::new(),
33 source_hash: Tile::hash_content(content),
34 constraints,
35 summary,
36 context_required: if salience.context_required.is_empty() {
37 self.infer_context(content)
38 } else {
39 salience.context_required.clone()
40 },
41 valence,
42 created_at: chrono::Utc::now(),
43 accessed_at: chrono::Utc::now(),
44 access_count: 0,
45 generation: 0,
46 parent_id: None,
47 }
48 }
49
50 pub fn encode_with_valence(&self, content: &str, valence: f64) -> Tile {
52 let salience = SalienceMap {
53 valence: Some(valence),
54 ..Default::default()
55 };
56 self.encode(content, &salience)
57 }
58
59 fn compress(&self, content: &str) -> String {
61 if content.len() <= self.max_summary_bytes {
62 return content.to_string();
63 }
64
65 let mut summary = String::new();
67 for sentence in content.split_inclusive(|c: char| c == '.' || c == '!' || c == '?') {
68 if summary.len() + sentence.len() > self.max_summary_bytes {
69 break;
70 }
71 summary.push_str(sentence);
72 }
73
74 if summary.is_empty() {
75 let end = content
77 .char_indices()
78 .take_while(|(i, _)| *i < self.max_summary_bytes)
79 .last()
80 .map(|(i, c)| i + c.len_utf8())
81 .unwrap_or(self.max_summary_bytes.min(content.len()));
82 summary = content[..end].to_string();
83 }
84
85 summary
86 }
87
88 fn infer_context(&self, content: &str) -> Vec<String> {
90 let mut ctx = Vec::new();
91 for word in content.split_whitespace() {
93 let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric());
94 if trimmed.starts_with(char::is_uppercase) && trimmed.len() > 2 {
95 ctx.push(trimmed.to_string());
96 }
97 }
98 ctx.dedup();
99 ctx.truncate(10);
100 ctx
101 }
102}
103
104#[derive(Default)]
106pub struct ConstraintExtractor;
107
108impl ConstraintExtractor {
109 pub fn extract(&self, content: &str) -> HashMap<String, String> {
111 let mut constraints = HashMap::new();
112
113 for word in content.split_whitespace() {
115 if word.chars().any(|c| c.is_ascii_digit()) {
116 let key = format!("num_{}", constraints.len());
117 constraints.insert(key, word.to_string());
118 }
119 }
120
121 let mut proper_nouns = Vec::new();
123 for word in content.split_whitespace() {
124 let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric());
125 if trimmed.starts_with(char::is_uppercase) && trimmed.len() > 2 && !proper_nouns.contains(&trimmed) {
126 proper_nouns.push(trimmed);
127 }
128 }
129 if !proper_nouns.is_empty() {
130 constraints.insert("proper_nouns".into(), proper_nouns.join(", "));
131 }
132
133 let mut in_quote = false;
135 let mut quoted = String::new();
136 let mut quotes = Vec::new();
137 for ch in content.chars() {
138 match ch {
139 '"' | '\'' if in_quote => {
140 in_quote = false;
141 if !quoted.is_empty() {
142 quotes.push(quoted.clone());
143 }
144 quoted.clear();
145 }
146 '"' | '\'' => {
147 in_quote = true;
148 }
149 _ if in_quote => {
150 quoted.push(ch);
151 }
152 _ => {}
153 }
154 }
155 if !quotes.is_empty() {
156 constraints.insert("quoted".into(), quotes.join("; "));
157 }
158
159 constraints
160 }
161
162 pub fn valence(&self, content: &str) -> f64 {
164 let high_valence = [
165 "amazing", "incredible", "excited", "love", "fantastic", "breakthrough",
166 "critical", "urgent", "important", "dramatic", "shocking", "unbelievable",
167 "urgent", "crisis", "emergency", "disaster", "triumph", "victory",
168 ];
169 let low_valence = [
170 "maybe", "perhaps", "minor", "routine", "normal", "fine", "okay",
171 "standard", "regular", "typical", "usual",
172 ];
173
174 let lower = content.to_lowercase();
175 let words: Vec<&str> = lower.split_whitespace().collect();
176
177 let high_count = words.iter().filter(|w| high_valence.contains(&w.as_ref())).count();
178 let low_count = words.iter().filter(|w| low_valence.contains(&w.as_ref())).count();
179
180 if high_count + low_count == 0 {
181 return 0.3; }
183
184 let base = high_count as f64 / (high_count + low_count) as f64;
185 let excl_boost = content.matches('!').count().min(5) as f64 * 0.05;
187 let caps_boost = content
188 .chars()
189 .filter(|c| c.is_uppercase())
190 .count()
191 .min(20) as f64
192 * 0.005;
193
194 (base + excl_boost + caps_boost).clamp(0.0, 1.0)
195 }
196
197 pub fn compression_ratio(&self, original: &str, tile: &Tile) -> f64 {
199 if original.is_empty() {
200 return 1.0;
201 }
202 tile.summary.len() as f64 / original.len() as f64
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn encode_basic() {
212 let encoder = TileEncoder::default();
213 let tile = encoder.encode("Hello world. This is a test.", &SalienceMap::default());
214 assert!(!tile.summary.is_empty());
215 assert_eq!(tile.generation, 0);
216 assert!(tile.parent_id.is_none());
217 }
218
219 #[test]
220 fn encode_with_valence() {
221 let encoder = TileEncoder::default();
222 let tile = encoder.encode_with_valence("Boring content.", 0.95);
223 assert!((tile.valence - 0.95).abs() < 0.001);
224 }
225
226 #[test]
227 fn compress_long_content() {
228 let encoder = TileEncoder {
229 max_summary_bytes: 50,
230 ..Default::default()
231 };
232 let long = "This is sentence one. This is sentence two. This is sentence three.";
233 let tile = encoder.encode(long, &SalienceMap::default());
234 assert!(tile.summary.len() <= 60); }
236
237 #[test]
238 fn extract_constraints() {
239 let extractor = ConstraintExtractor;
240 let constraints = extractor.extract("Alice met Bob at 42nd Street on March 15th.");
241 assert!(constraints.contains_key("proper_nouns"));
242 assert!(constraints.values().any(|v| v.contains("42nd")));
243 }
244
245 #[test]
246 fn valence_high() {
247 let extractor = ConstraintExtractor;
248 let v = extractor.valence("This is an amazing breakthrough!! Incredible results!");
249 assert!(v > 0.5, "expected high valence, got {}", v);
250 }
251
252 #[test]
253 fn valence_low() {
254 let extractor = ConstraintExtractor;
255 let v = extractor.valence("This is a routine update. Nothing special.");
256 assert!(v < 0.5, "expected low valence, got {}", v);
257 }
258
259 #[test]
260 fn compression_ratio() {
261 let extractor = ConstraintExtractor;
262 let tile = Tile {
263 id: TileId::new(),
264 source_hash: String::new(),
265 constraints: HashMap::new(),
266 summary: "short".into(),
267 context_required: vec![],
268 valence: 0.5,
269 created_at: chrono::Utc::now(),
270 accessed_at: chrono::Utc::now(),
271 access_count: 0,
272 generation: 0,
273 parent_id: None,
274 };
275 let ratio = extractor.compression_ratio("a much longer original content string", &tile);
276 assert!(ratio < 1.0);
277 assert!(ratio > 0.0);
278 }
279}