use lago_core::error::LagoResult;
use lago_core::event::{EventEnvelope, EventPayload, MemoryScope};
use lago_core::projection::Projection;
use std::collections::{BTreeMap, HashSet};
#[derive(Debug, Clone)]
pub struct CommittedMemory {
pub memory_id: String,
pub committed_ref: String,
pub supersedes: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MemoryQueryResult {
pub observations: Vec<String>,
pub reflection: Option<String>,
pub committed: Vec<CommittedMemory>,
}
pub struct MemoryProjection {
observations: BTreeMap<MemoryScope, Vec<String>>,
reflections: BTreeMap<MemoryScope, String>,
committed: BTreeMap<MemoryScope, Vec<CommittedMemory>>,
tombstoned: HashSet<String>,
}
impl MemoryProjection {
pub fn new() -> Self {
Self {
observations: BTreeMap::new(),
reflections: BTreeMap::new(),
committed: BTreeMap::new(),
tombstoned: HashSet::new(),
}
}
pub fn query(&self, scope: MemoryScope) -> MemoryQueryResult {
let observations = self.observations.get(&scope).cloned().unwrap_or_default();
let reflection = self.reflections.get(&scope).cloned();
let committed = self
.committed
.get(&scope)
.map(|entries| {
entries
.iter()
.filter(|e| !self.tombstoned.contains(&e.memory_id))
.cloned()
.collect()
})
.unwrap_or_default();
MemoryQueryResult {
observations,
reflection,
committed,
}
}
pub fn latest_reflection(&self, scope: MemoryScope) -> Option<&str> {
self.reflections.get(&scope).map(String::as_str)
}
pub fn observation_count(&self, scope: MemoryScope) -> usize {
self.observations.get(&scope).map(Vec::len).unwrap_or(0)
}
}
impl Default for MemoryProjection {
fn default() -> Self {
Self::new()
}
}
impl Projection for MemoryProjection {
fn on_event(&mut self, event: &EventEnvelope) -> LagoResult<()> {
match &event.payload {
EventPayload::ObservationAppended {
scope,
observation_ref,
..
} => {
self.observations
.entry(*scope)
.or_default()
.push(observation_ref.to_string());
}
EventPayload::ReflectionCompacted {
scope, summary_ref, ..
} => {
self.reflections.insert(*scope, summary_ref.to_string());
}
EventPayload::MemoryCommitted {
scope,
memory_id,
committed_ref,
supersedes,
} => {
if let Some(old_id) = supersedes {
self.tombstoned.insert(old_id.to_string());
}
self.committed
.entry(*scope)
.or_default()
.push(CommittedMemory {
memory_id: memory_id.to_string(),
committed_ref: committed_ref.to_string(),
supersedes: supersedes.as_ref().map(ToString::to_string),
});
}
EventPayload::MemoryTombstoned { memory_id, .. } => {
self.tombstoned.insert(memory_id.to_string());
}
_ => {}
}
Ok(())
}
fn name(&self) -> &str {
"arcan::memory"
}
}
#[cfg(test)]
mod tests {
use super::*;
use lago_core::event::{EventEnvelope, EventPayload, MemoryScope};
use lago_core::id::*;
use std::collections::HashMap;
fn make_envelope(seq: u64, payload: EventPayload) -> EventEnvelope {
EventEnvelope {
event_id: EventId::from_string("EVT001"),
session_id: SessionId::from_string("SESS001"),
branch_id: BranchId::from_string("main"),
run_id: None,
seq,
timestamp: 1_700_000_000_000_000 + seq,
parent_id: None,
payload,
metadata: HashMap::new(),
schema_version: 1,
}
}
#[test]
fn tracks_per_scope_observations() {
let mut proj = MemoryProjection::new();
proj.on_event(&make_envelope(
1,
EventPayload::ObservationAppended {
scope: MemoryScope::Session,
observation_ref: BlobHash::from_hex("obs1").into(),
source_run_id: None,
},
))
.unwrap();
proj.on_event(&make_envelope(
2,
EventPayload::ObservationAppended {
scope: MemoryScope::User,
observation_ref: BlobHash::from_hex("obs2").into(),
source_run_id: None,
},
))
.unwrap();
proj.on_event(&make_envelope(
3,
EventPayload::ObservationAppended {
scope: MemoryScope::Session,
observation_ref: BlobHash::from_hex("obs3").into(),
source_run_id: None,
},
))
.unwrap();
assert_eq!(proj.observation_count(MemoryScope::Session), 2);
assert_eq!(proj.observation_count(MemoryScope::User), 1);
assert_eq!(proj.observation_count(MemoryScope::Agent), 0);
}
#[test]
fn stores_reflections() {
let mut proj = MemoryProjection::new();
proj.on_event(&make_envelope(
1,
EventPayload::ReflectionCompacted {
scope: MemoryScope::Session,
summary_ref: BlobHash::from_hex("summary1").into(),
covers_through_seq: 10,
},
))
.unwrap();
assert_eq!(
proj.latest_reflection(MemoryScope::Session),
Some("summary1")
);
assert_eq!(proj.latest_reflection(MemoryScope::User), None);
proj.on_event(&make_envelope(
2,
EventPayload::ReflectionCompacted {
scope: MemoryScope::Session,
summary_ref: BlobHash::from_hex("summary2").into(),
covers_through_seq: 20,
},
))
.unwrap();
assert_eq!(
proj.latest_reflection(MemoryScope::Session),
Some("summary2")
);
}
#[test]
fn tracks_committed_memories() {
let mut proj = MemoryProjection::new();
proj.on_event(&make_envelope(
1,
EventPayload::MemoryCommitted {
scope: MemoryScope::User,
memory_id: MemoryId::from_string("MEM001").into(),
committed_ref: BlobHash::from_hex("ref1").into(),
supersedes: None,
},
))
.unwrap();
let result = proj.query(MemoryScope::User);
assert_eq!(result.committed.len(), 1);
assert_eq!(result.committed[0].memory_id, "MEM001");
}
#[test]
fn respects_tombstones() {
let mut proj = MemoryProjection::new();
proj.on_event(&make_envelope(
1,
EventPayload::MemoryCommitted {
scope: MemoryScope::User,
memory_id: MemoryId::from_string("MEM001").into(),
committed_ref: BlobHash::from_hex("ref1").into(),
supersedes: None,
},
))
.unwrap();
proj.on_event(&make_envelope(
2,
EventPayload::MemoryTombstoned {
scope: MemoryScope::User,
memory_id: MemoryId::from_string("MEM001").into(),
reason: "outdated".to_string(),
},
))
.unwrap();
let result = proj.query(MemoryScope::User);
assert!(
result.committed.is_empty(),
"tombstoned memories should be excluded"
);
}
#[test]
fn handles_supersedes() {
let mut proj = MemoryProjection::new();
proj.on_event(&make_envelope(
1,
EventPayload::MemoryCommitted {
scope: MemoryScope::User,
memory_id: MemoryId::from_string("MEM001").into(),
committed_ref: BlobHash::from_hex("ref1").into(),
supersedes: None,
},
))
.unwrap();
proj.on_event(&make_envelope(
2,
EventPayload::MemoryCommitted {
scope: MemoryScope::User,
memory_id: MemoryId::from_string("MEM002").into(),
committed_ref: BlobHash::from_hex("ref2").into(),
supersedes: Some(MemoryId::from_string("MEM001").into()),
},
))
.unwrap();
let result = proj.query(MemoryScope::User);
assert_eq!(result.committed.len(), 1);
assert_eq!(result.committed[0].memory_id, "MEM002");
}
#[test]
fn query_returns_correct_data() {
let mut proj = MemoryProjection::new();
proj.on_event(&make_envelope(
1,
EventPayload::ObservationAppended {
scope: MemoryScope::Session,
observation_ref: BlobHash::from_hex("obs1").into(),
source_run_id: Some("run-1".to_string()),
},
))
.unwrap();
proj.on_event(&make_envelope(
2,
EventPayload::ReflectionCompacted {
scope: MemoryScope::Session,
summary_ref: BlobHash::from_hex("sum1").into(),
covers_through_seq: 1,
},
))
.unwrap();
proj.on_event(&make_envelope(
3,
EventPayload::MemoryCommitted {
scope: MemoryScope::Session,
memory_id: MemoryId::from_string("MEM001").into(),
committed_ref: BlobHash::from_hex("ref1").into(),
supersedes: None,
},
))
.unwrap();
let result = proj.query(MemoryScope::Session);
assert_eq!(result.observations.len(), 1);
assert_eq!(result.observations[0], "obs1");
assert_eq!(result.reflection.as_deref(), Some("sum1"));
assert_eq!(result.committed.len(), 1);
let empty_result = proj.query(MemoryScope::Org);
assert!(empty_result.observations.is_empty());
assert!(empty_result.reflection.is_none());
assert!(empty_result.committed.is_empty());
}
#[test]
fn projection_name() {
let proj = MemoryProjection::new();
assert_eq!(proj.name(), "arcan::memory");
}
}