Skip to main content

wm_memory/
typology.rs

1//! Typology classes and write-path policy (V8 S5, `MEMORY_TYPOLOGY_V8.md`
2//! §2–§3).
3//!
4//! Five classes. Class is stamped at creation, before importance is
5//! assigned; importance is derived from class + content, **never
6//! caller-chosen** where the class policy says so. The detector is
7//! deliberately conservative: it stamps only what it *confidently*
8//! recognizes (template shapes, tag families, session-JSON shape) —
9//! unrecognized content stays `None` (unstamped) rather than being
10//! guessed into a class whose floors it does not deserve. Template
11//! mining (typology §4) widens this set with evidence; the census scripts
12//! are the upstream evidence source.
13
14use crate::memory::Tier;
15use serde::{Deserialize, Serialize};
16
17/// Typology class — what family a memory belongs to, decided at the
18/// write path.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum MemoryClass {
22    /// User/agent turns, session decisions — the conversation record.
23    Dialogue,
24    /// Strategy docs, lessons, verified claims.
25    Knowledge,
26    /// Friction records, karma events, RSI auto-logs — the salience
27    /// inversion's former winner; ceiling-capped.
28    Telemetry,
29    /// Heritage chunks, bulk transcripts — sealed, ceiling-capped.
30    RawArchive,
31    /// Dedup stubs, rollups, references — points at content.
32    Pointer,
33}
34
35impl MemoryClass {
36    /// String label for JSON / display.
37    #[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
49/// Template prefixes that mark telemetry by construction — the
50/// Template prefixes that mark telemetry by construction — every prefix
51/// here is emitted by code in this repo (evidence-locked, no speculative
52/// patterns): the RSI recorder's friction family (`rsi.rs`), the
53/// friction.log tool's plain form, and the daemon's WS-4 improvement
54/// proposals (`daemon.rs`, which also carries `rsi:proposal` tags).
55const TELEMETRY_TEMPLATE_PREFIXES: [&str; 3] = [
56    "## Auto-logged Friction:",
57    "## Friction:",
58    "## Improvement Proposal",
59];
60
61/// Class detection from content shape + tag families.
62///
63/// Returns `None` when nothing is confidently recognized — the honest
64/// residue. Never guesses `Knowledge` (its floor would then be granted
65/// to arbitrary prose, recreating the inversion this slice exists to
66/// kill).
67#[must_use]
68pub fn detect_class(content: &str, tags: &[String]) -> Option<MemoryClass> {
69    // Tag families first — they carry provenance the content shape lacks.
70    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    // Telemetry template shapes (the junk filter's recognizer).
84    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    // Session-record shape: the JSON envelope the session tools write
93    // (role + session_id keys) — dialogue by construction. The session
94    // start marker carries the `start` tag.
95    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/// Class-based importance policy (typology §2) — the plausibility gate's
112/// rule set, applied to the caller- or default-requested importance.
113///
114/// - `Dialogue`: floor 0.75 — a session decision can never rank below
115///   friction telemetry's ceiling.
116/// - `Telemetry`: **ceiling 0.40** — the salience inversion's fix.
117/// - `RawArchive`: ceiling 0.30, sealed.
118/// - `Knowledge` / `Pointer`: untouched in v0 — knowledge's 0.7 floor
119///   waits for template mining (§4) to detect it confidently; flooring
120///   the unrecognized residue would flood the ≥0.7 band with noise.
121#[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/// Initial lifecycle tier for a freshly stamped class (typology §6):
132/// fresh writes land hot (Working) and age out via the dream cycle;
133/// heritage/raw-archive content is born cold.
134#[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        // JSON that lacks the session-record shape is not dialogue.
193        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        // The acceptance invariant: a dialogue record at its floor still
210        // outranks a telemetry record at its ceiling.
211        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}