Skip to main content

rig_memory_policy/
metadata.rs

1//! Typed metadata envelope for entries written by memory-lifecycle hooks.
2//!
3//! Backends stamp a [`FrameMetadata`] envelope into their per-entry metadata
4//! map (e.g. `memvid_core::SearchHitMetadata::extra_metadata`, or any
5//! equivalent key-value bag on another backend) so downstream tools — evals,
6//! memory inspectors, RAG pipelines — can decode the origin of each retrieved
7//! entry uniformly regardless of the storage engine.
8
9use serde::{Deserialize, Serialize};
10
11/// The type of entry produced by a memory lifecycle hook.
12///
13/// Backends pattern-match on this enum across crate boundaries (e.g. to
14/// classify retrieved entries by origin), so the enum is **not**
15/// `#[non_exhaustive]`. Any future variant addition is therefore a breaking
16/// change and must bump the major version of this crate.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum FrameKind {
20    /// A single user/assistant interaction evicted from the working set.
21    DemotedMessage,
22    /// A rolled-up summary of multiple evicted interactions.
23    CompactionSummary,
24}
25
26/// The typed schema written into a backend's per-entry metadata map for every
27/// entry produced by a Rig memory lifecycle hook.
28///
29/// Downstream tools can decode the metadata map into this struct to reason
30/// about the origin of the retrieved entry.
31///
32/// Backends construct this envelope directly (struct literal); to keep that
33/// usage ergonomic across crate boundaries the struct is **not**
34/// `#[non_exhaustive]`. Any future field addition is therefore a breaking
35/// change and must bump the major version of this crate.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct FrameMetadata {
38    /// The schema version of this envelope. Currently `1`.
39    pub schema_version: u8,
40    /// What kind of memory lifecycle produced this entry.
41    pub kind: FrameKind,
42    /// The conversation ID from the Rig `CompactingMemory` context.
43    pub conversation_id: String,
44    /// The logical chat role (`system`, `user`, `assistant`).
45    pub chat_role: String,
46    /// The deterministic content-hash dedup key.
47    pub dedup_key: String,
48    /// The isolation scope under which this entry was written, if any.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub scope: Option<String>,
51}
52
53impl FrameMetadata {
54    /// Attempt to parse the metadata envelope from a string-to-string
55    /// dictionary.
56    pub fn try_from_map(
57        map: &std::collections::BTreeMap<String, String>,
58    ) -> Result<Self, serde_json::Error> {
59        let mut obj = serde_json::Map::new();
60        #[allow(clippy::collapsible_if)]
61        for (k, v) in map {
62            if let Ok(num) = v.parse::<u8>() {
63                if k == "schema_version" {
64                    obj.insert(k.clone(), serde_json::Value::Number(num.into()));
65                    continue;
66                }
67            }
68            obj.insert(k.clone(), serde_json::Value::String(v.clone()));
69        }
70        serde_json::from_value(serde_json::Value::Object(obj))
71    }
72
73    /// Convert the metadata envelope into a string-to-string dictionary.
74    pub fn into_map(self) -> std::collections::BTreeMap<String, String> {
75        let mut map = std::collections::BTreeMap::new();
76        map.insert(
77            "schema_version".to_string(),
78            self.schema_version.to_string(),
79        );
80        map.insert("kind".to_string(), self.kind.as_str().to_string());
81        map.insert("conversation_id".to_string(), self.conversation_id);
82        map.insert("chat_role".to_string(), self.chat_role);
83        map.insert("dedup_key".to_string(), self.dedup_key);
84        if let Some(scope) = self.scope {
85            map.insert("scope".to_string(), scope);
86        }
87        map
88    }
89}
90
91impl FrameKind {
92    /// Provide the snake_case string representation.
93    pub fn as_str(self) -> &'static str {
94        match self {
95            Self::DemotedMessage => "demoted_message",
96            Self::CompactionSummary => "compaction_summary",
97        }
98    }
99}
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn round_trips_through_map() {
108        let original = FrameMetadata {
109            schema_version: 1,
110            kind: FrameKind::DemotedMessage,
111            conversation_id: "conv-1".to_string(),
112            chat_role: "user".to_string(),
113            dedup_key: "deadbeef".to_string(),
114            scope: Some("scope-a".to_string()),
115        };
116        let map = original.clone().into_map();
117        let parsed = FrameMetadata::try_from_map(&map).unwrap();
118        assert_eq!(parsed, original);
119    }
120
121    #[test]
122    fn scope_round_trips_when_absent() {
123        let original = FrameMetadata {
124            schema_version: 1,
125            kind: FrameKind::CompactionSummary,
126            conversation_id: "conv-2".to_string(),
127            chat_role: "assistant".to_string(),
128            dedup_key: "cafebabe".to_string(),
129            scope: None,
130        };
131        let map = original.clone().into_map();
132        assert!(!map.contains_key("scope"));
133        let parsed = FrameMetadata::try_from_map(&map).unwrap();
134        assert_eq!(parsed, original);
135    }
136}