1use crate::memory::Tier;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum MemoryClass {
22 Dialogue,
24 Knowledge,
26 Telemetry,
29 RawArchive,
31 Pointer,
33}
34
35impl MemoryClass {
36 #[must_use]
38 pub const fn as_str(self) -> &'static str {
39 match self {
40 Self::Dialogue => "dialogue",
41 Self::Knowledge => "knowledge",
42 Self::Telemetry => "telemetry",
43 Self::RawArchive => "raw_archive",
44 Self::Pointer => "pointer",
45 }
46 }
47}
48
49const TELEMETRY_TEMPLATE_PREFIXES: [&str; 3] = [
56 "## Auto-logged Friction:",
57 "## Friction:",
58 "## Improvement Proposal",
59];
60
61#[must_use]
68pub fn detect_class(content: &str, tags: &[String]) -> Option<MemoryClass> {
69 for tag in tags {
71 let t = tag.as_str();
72 if t.starts_with("rsi:") || t == "friction" || t.starts_with("friction:") {
73 return Some(MemoryClass::Telemetry);
74 }
75 if t.starts_with("ingest:") || t == "heritage" || t.starts_with("heritage:") {
76 return Some(MemoryClass::RawArchive);
77 }
78 if t == "pointer" || t == "dedup-stub" || t == "rollup" {
79 return Some(MemoryClass::Pointer);
80 }
81 }
82
83 let trimmed = content.trim_start();
85 if TELEMETRY_TEMPLATE_PREFIXES
86 .iter()
87 .any(|p| trimmed.starts_with(p))
88 {
89 return Some(MemoryClass::Telemetry);
90 }
91
92 if tags.iter().any(|t| t == "start") {
96 return Some(MemoryClass::Dialogue);
97 }
98 if trimmed.starts_with('{') {
99 if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
100 let has_role = v.get("role").is_some();
101 let has_session = v.get("session_id").is_some();
102 if has_role && has_session {
103 return Some(MemoryClass::Dialogue);
104 }
105 }
106 }
107
108 None
109}
110
111#[must_use]
122pub const fn apply_class_policy(class: MemoryClass, importance: f32) -> f32 {
123 match class {
124 MemoryClass::Dialogue => importance.max(0.75),
125 MemoryClass::Telemetry => importance.min(0.40),
126 MemoryClass::RawArchive => importance.min(0.30),
127 MemoryClass::Knowledge | MemoryClass::Pointer => importance,
128 }
129}
130
131#[must_use]
135pub const fn initial_tier(class: MemoryClass) -> Tier {
136 match class {
137 MemoryClass::RawArchive => Tier::Archival,
138 _ => Tier::Working,
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn friction_templates_detect_telemetry() {
148 for template in [
149 "## Auto-logged Friction: Tool dispatch error (REGRESSION)\n\nbody",
150 "## Friction: what happened\n\n**Expected:** x",
151 "## Improvement Proposal\n\n**Category:** hygiene\n\n**Severity:** low",
152 ] {
153 assert_eq!(
154 detect_class(template, &[]),
155 Some(MemoryClass::Telemetry),
156 "{template}"
157 );
158 }
159 }
160
161 #[test]
162 fn tag_families_detect_classes() {
163 let tags = |t: &[&str]| -> Vec<String> { t.iter().map(|s| (*s).to_string()).collect() };
164 assert_eq!(
165 detect_class("anything", &tags(&["rsi:hash:abcdef0123456789"])),
166 Some(MemoryClass::Telemetry)
167 );
168 assert_eq!(
169 detect_class("chunk text", &tags(&["source:heritage", "ingest:v5"])),
170 Some(MemoryClass::RawArchive)
171 );
172 assert_eq!(
173 detect_class("stub", &tags(&["pointer"])),
174 Some(MemoryClass::Pointer)
175 );
176 }
177
178 #[test]
179 fn session_json_shape_detects_dialogue() {
180 let turn = r#"{"role":"ai","content":"decision text","session_id":"abc"}"#;
181 assert_eq!(detect_class(turn, &[]), Some(MemoryClass::Dialogue));
182 let start = "plain marker content";
183 assert_eq!(
184 detect_class(start, &["start".to_string()]),
185 Some(MemoryClass::Dialogue)
186 );
187 }
188
189 #[test]
190 fn unrecognized_content_stays_unstamped() {
191 assert_eq!(detect_class("a normal thought about kumquats", &[]), None);
192 assert_eq!(detect_class(r#"{"foo": 1}"#, &[]), None);
194 }
195
196 #[test]
197 fn class_policy_floors_dialogue_and_caps_telemetry() {
198 assert_eq!(apply_class_policy(MemoryClass::Dialogue, 0.5), 0.75);
199 assert_eq!(apply_class_policy(MemoryClass::Dialogue, 0.9), 0.9);
200 assert_eq!(apply_class_policy(MemoryClass::Telemetry, 0.9), 0.40);
201 assert_eq!(apply_class_policy(MemoryClass::Telemetry, 0.2), 0.2);
202 assert_eq!(apply_class_policy(MemoryClass::RawArchive, 0.8), 0.30);
203 assert_eq!(apply_class_policy(MemoryClass::Knowledge, 0.5), 0.5);
204 assert_eq!(apply_class_policy(MemoryClass::Pointer, 0.5), 0.5);
205 }
206
207 #[test]
208 fn dialogue_floor_dominate_telemetry_ceiling_by_construction() {
209 assert!(
212 apply_class_policy(MemoryClass::Dialogue, 0.0)
213 > apply_class_policy(MemoryClass::Telemetry, 1.0)
214 );
215 }
216
217 #[test]
218 fn initial_tier_born_hot_except_archival() {
219 assert_eq!(initial_tier(MemoryClass::Dialogue), Tier::Working);
220 assert_eq!(initial_tier(MemoryClass::Telemetry), Tier::Working);
221 assert_eq!(initial_tier(MemoryClass::RawArchive), Tier::Archival);
222 }
223
224 #[test]
225 fn class_serde_roundtrip_snake_case() {
226 let json = serde_json::to_string(&MemoryClass::RawArchive).unwrap();
227 assert_eq!(json, "\"raw_archive\"");
228 let back: MemoryClass = serde_json::from_str(&json).unwrap();
229 assert_eq!(back, MemoryClass::RawArchive);
230 }
231}