use crate::memory::Tier;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryClass {
Dialogue,
Knowledge,
Telemetry,
RawArchive,
Pointer,
}
impl MemoryClass {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Dialogue => "dialogue",
Self::Knowledge => "knowledge",
Self::Telemetry => "telemetry",
Self::RawArchive => "raw_archive",
Self::Pointer => "pointer",
}
}
}
const TELEMETRY_TEMPLATE_PREFIXES: [&str; 3] = [
"## Auto-logged Friction:",
"## Friction:",
"## Improvement Proposal",
];
#[must_use]
pub fn detect_class(content: &str, tags: &[String]) -> Option<MemoryClass> {
for tag in tags {
let t = tag.as_str();
if t.starts_with("rsi:") || t == "friction" || t.starts_with("friction:") {
return Some(MemoryClass::Telemetry);
}
if t.starts_with("ingest:") || t == "heritage" || t.starts_with("heritage:") {
return Some(MemoryClass::RawArchive);
}
if t == "pointer" || t == "dedup-stub" || t == "rollup" {
return Some(MemoryClass::Pointer);
}
}
let trimmed = content.trim_start();
if TELEMETRY_TEMPLATE_PREFIXES
.iter()
.any(|p| trimmed.starts_with(p))
{
return Some(MemoryClass::Telemetry);
}
if tags.iter().any(|t| t == "start") {
return Some(MemoryClass::Dialogue);
}
if trimmed.starts_with('{') {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
let has_role = v.get("role").is_some();
let has_session = v.get("session_id").is_some();
if has_role && has_session {
return Some(MemoryClass::Dialogue);
}
}
}
None
}
#[must_use]
pub const fn apply_class_policy(class: MemoryClass, importance: f32) -> f32 {
match class {
MemoryClass::Dialogue => importance.max(0.75),
MemoryClass::Telemetry => importance.min(0.40),
MemoryClass::RawArchive => importance.min(0.30),
MemoryClass::Knowledge | MemoryClass::Pointer => importance,
}
}
#[must_use]
pub const fn initial_tier(class: MemoryClass) -> Tier {
match class {
MemoryClass::RawArchive => Tier::Archival,
_ => Tier::Working,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn friction_templates_detect_telemetry() {
for template in [
"## Auto-logged Friction: Tool dispatch error (REGRESSION)\n\nbody",
"## Friction: what happened\n\n**Expected:** x",
"## Improvement Proposal\n\n**Category:** hygiene\n\n**Severity:** low",
] {
assert_eq!(
detect_class(template, &[]),
Some(MemoryClass::Telemetry),
"{template}"
);
}
}
#[test]
fn tag_families_detect_classes() {
let tags = |t: &[&str]| -> Vec<String> { t.iter().map(|s| (*s).to_string()).collect() };
assert_eq!(
detect_class("anything", &tags(&["rsi:hash:abcdef0123456789"])),
Some(MemoryClass::Telemetry)
);
assert_eq!(
detect_class("chunk text", &tags(&["source:heritage", "ingest:v5"])),
Some(MemoryClass::RawArchive)
);
assert_eq!(
detect_class("stub", &tags(&["pointer"])),
Some(MemoryClass::Pointer)
);
}
#[test]
fn session_json_shape_detects_dialogue() {
let turn = r#"{"role":"ai","content":"decision text","session_id":"abc"}"#;
assert_eq!(detect_class(turn, &[]), Some(MemoryClass::Dialogue));
let start = "plain marker content";
assert_eq!(
detect_class(start, &["start".to_string()]),
Some(MemoryClass::Dialogue)
);
}
#[test]
fn unrecognized_content_stays_unstamped() {
assert_eq!(detect_class("a normal thought about kumquats", &[]), None);
assert_eq!(detect_class(r#"{"foo": 1}"#, &[]), None);
}
#[test]
fn class_policy_floors_dialogue_and_caps_telemetry() {
assert_eq!(apply_class_policy(MemoryClass::Dialogue, 0.5), 0.75);
assert_eq!(apply_class_policy(MemoryClass::Dialogue, 0.9), 0.9);
assert_eq!(apply_class_policy(MemoryClass::Telemetry, 0.9), 0.40);
assert_eq!(apply_class_policy(MemoryClass::Telemetry, 0.2), 0.2);
assert_eq!(apply_class_policy(MemoryClass::RawArchive, 0.8), 0.30);
assert_eq!(apply_class_policy(MemoryClass::Knowledge, 0.5), 0.5);
assert_eq!(apply_class_policy(MemoryClass::Pointer, 0.5), 0.5);
}
#[test]
fn dialogue_floor_dominate_telemetry_ceiling_by_construction() {
assert!(
apply_class_policy(MemoryClass::Dialogue, 0.0)
> apply_class_policy(MemoryClass::Telemetry, 1.0)
);
}
#[test]
fn initial_tier_born_hot_except_archival() {
assert_eq!(initial_tier(MemoryClass::Dialogue), Tier::Working);
assert_eq!(initial_tier(MemoryClass::Telemetry), Tier::Working);
assert_eq!(initial_tier(MemoryClass::RawArchive), Tier::Archival);
}
#[test]
fn class_serde_roundtrip_snake_case() {
let json = serde_json::to_string(&MemoryClass::RawArchive).unwrap();
assert_eq!(json, "\"raw_archive\"");
let back: MemoryClass = serde_json::from_str(&json).unwrap();
assert_eq!(back, MemoryClass::RawArchive);
}
}