kiromi-ai-memory 0.2.2

Local-first multi-tenant memory store engine: Markdown/text content on object storage, metadata in SQLite, plugin-shaped embedder/storage/metadata, hybrid text+vector search.
Documentation
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Plan 16: atomic batch evolution of the memory catalog.
//!
//! [`EvolutionOps`] groups every op an agent might want to apply when a
//! new memory arrives — mark older facts invalid, attach new keywords,
//! record a `Supersedes` link, re-categorise an episodic fragment as
//! `Semantic`, etc. [`crate::Memory::evolve`] applies the whole batch
//! in a single SQL transaction, so the catalog never lands in a
//! half-applied state.
//!
//! See spec § 12.X (Plan 16) for the design rationale.

use serde::{Deserialize, Serialize};

use crate::attribute::AttributeValue;
use crate::graph::NodeRef;
use crate::link::LinkKind;
use crate::memory::{MemoryKind, MemoryRef};
use crate::summary::SummaryRef;

/// One attribute change targeted at a memory row.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum MemoryAttributeOp {
    /// Upsert one attribute on the target memory.
    Set {
        /// Target memory.
        mref: MemoryRef,
        /// Attribute key.
        key: String,
        /// Attribute value.
        value: AttributeValue,
    },
    /// Remove one attribute on the target memory.
    Clear {
        /// Target memory.
        mref: MemoryRef,
        /// Attribute key.
        key: String,
    },
}

/// One attribute change targeted at a summary row.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum SummaryAttributeOp {
    /// Upsert one attribute on the target summary.
    Set {
        /// Target summary.
        sref: SummaryRef,
        /// Attribute key.
        key: String,
        /// Attribute value.
        value: AttributeValue,
    },
    /// Remove one attribute on the target summary.
    Clear {
        /// Target summary.
        sref: SummaryRef,
        /// Attribute key.
        key: String,
    },
}

/// A coordinated batch of catalog updates triggered by the arrival of
/// `trigger`.
///
/// Every field is optional (default-empty); use
/// [`EvolutionOps::triggered_by`] + builder helpers (or direct field
/// assignment via `..Default::default()`) to populate the bits an
/// agent wants to apply.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EvolutionOps {
    /// The new memory whose arrival triggered this evolution. Recorded
    /// in the audit log + emitted on [`crate::MemoryEvent::Evolved`].
    pub trigger: Option<MemoryRef>,
    /// Optional caller-supplied note. Recorded in audit.
    pub note: Option<String>,
    /// Per-target attribute changes on historical memories.
    pub memory_attributes: Vec<MemoryAttributeOp>,
    /// Per-target attribute changes on historical summaries.
    pub summary_attributes: Vec<SummaryAttributeOp>,
    /// New links to record (e.g. agent decides M_new Supersedes M_old).
    pub links_added: Vec<(NodeRef, NodeRef, LinkKind)>,
    /// Existing links to remove.
    pub links_removed: Vec<(NodeRef, NodeRef, LinkKind)>,
    /// Close out historical facts (no body changes, just validity range).
    pub validity_updates: Vec<(MemoryRef, Option<i64>, Option<i64>)>,
    /// Re-categorise historical memories.
    pub kind_updates: Vec<(MemoryRef, MemoryKind)>,
}

impl EvolutionOps {
    /// Construct a fresh batch triggered by the supplied memory. Every
    /// other field is empty; populate via the public fields or chain
    /// helpers.
    #[must_use]
    pub fn triggered_by(trigger: MemoryRef) -> Self {
        EvolutionOps {
            trigger: Some(trigger),
            ..Self::default()
        }
    }

    /// Attach a caller-supplied note that will be recorded in the audit
    /// row.
    #[must_use]
    pub fn with_note(mut self, note: impl Into<String>) -> Self {
        self.note = Some(note.into());
        self
    }

    /// Record a `Set` op on a memory attribute.
    #[must_use]
    pub fn set_memory_attribute(
        mut self,
        mref: MemoryRef,
        key: impl Into<String>,
        value: impl Into<AttributeValue>,
    ) -> Self {
        self.memory_attributes.push(MemoryAttributeOp::Set {
            mref,
            key: key.into(),
            value: value.into(),
        });
        self
    }

    /// Record a `Clear` op on a memory attribute.
    #[must_use]
    pub fn clear_memory_attribute(mut self, mref: MemoryRef, key: impl Into<String>) -> Self {
        self.memory_attributes.push(MemoryAttributeOp::Clear {
            mref,
            key: key.into(),
        });
        self
    }

    /// Record a `Set` op on a summary attribute.
    #[must_use]
    pub fn set_summary_attribute(
        mut self,
        sref: SummaryRef,
        key: impl Into<String>,
        value: impl Into<AttributeValue>,
    ) -> Self {
        self.summary_attributes.push(SummaryAttributeOp::Set {
            sref,
            key: key.into(),
            value: value.into(),
        });
        self
    }

    /// Add a typed node→node link to the batch.
    #[must_use]
    pub fn add_link(mut self, src: NodeRef, dst: NodeRef, kind: LinkKind) -> Self {
        self.links_added.push((src, dst, kind));
        self
    }

    /// Remove a typed node→node link.
    #[must_use]
    pub fn remove_link(mut self, src: NodeRef, dst: NodeRef, kind: LinkKind) -> Self {
        self.links_removed.push((src, dst, kind));
        self
    }

    /// Close out a fact's validity. Either bound `None` means the
    /// caller is leaving that side untouched (the SQL update writes
    /// whatever the call carries).
    #[must_use]
    pub fn close_validity(
        mut self,
        mref: MemoryRef,
        from_ms: Option<i64>,
        until_ms: Option<i64>,
    ) -> Self {
        self.validity_updates.push((mref, from_ms, until_ms));
        self
    }

    /// Re-categorise a memory's `MemoryKind`.
    #[must_use]
    pub fn change_kind(mut self, mref: MemoryRef, kind: MemoryKind) -> Self {
        self.kind_updates.push((mref, kind));
        self
    }

    /// True iff every list is empty (and nothing would be applied).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.memory_attributes.is_empty()
            && self.summary_attributes.is_empty()
            && self.links_added.is_empty()
            && self.links_removed.is_empty()
            && self.validity_updates.is_empty()
            && self.kind_updates.is_empty()
    }

    /// Total op count.
    #[must_use]
    pub fn op_count(&self) -> u64 {
        let mut n: u64 = 0;
        n += self.memory_attributes.len() as u64;
        n += self.summary_attributes.len() as u64;
        n += self.links_added.len() as u64;
        n += self.links_removed.len() as u64;
        n += self.validity_updates.len() as u64;
        n += self.kind_updates.len() as u64;
        n
    }
}

/// Result of [`crate::metadata::MetadataStore::apply_evolution`].
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EvolutionApplied {
    /// Number of ops applied (matches `EvolutionOps::op_count` on
    /// success).
    pub applied: u64,
    /// New audit-log seq.
    pub audit_seq: i64,
}

/// Public report returned by [`crate::Memory::evolve`].
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EvolutionReport {
    /// Number of ops applied successfully.
    pub applied: u64,
    /// New audit-log seq.
    pub audit_seq: i64,
    /// How many post-commit events were broadcast (currently always
    /// `1` — the single `MemoryEvent::Evolved`).
    pub events_emitted: u32,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::memory::MemoryId;
    use crate::partition::PartitionPath;

    fn mref() -> MemoryRef {
        MemoryRef {
            id: MemoryId::generate(),
            partition: "p".parse::<PartitionPath>().unwrap(),
        }
    }

    #[test]
    fn default_is_empty() {
        let ops = EvolutionOps::default();
        assert!(ops.is_empty());
        assert_eq!(ops.op_count(), 0);
    }

    #[test]
    fn builders_accumulate() {
        let r = mref();
        let ops = EvolutionOps::triggered_by(r.clone())
            .with_note("agent decided")
            .set_memory_attribute(r.clone(), "k", "v")
            .clear_memory_attribute(r.clone(), "old")
            .close_validity(r.clone(), Some(1), Some(2))
            .change_kind(r, MemoryKind::Semantic);
        assert_eq!(ops.op_count(), 4);
        assert_eq!(ops.note.as_deref(), Some("agent decided"));
    }
}