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 11: typed metadata attributes attached to memory rows.
//!
//! Six variants — `String`, `Int`, `Decimal` (exact arithmetic via
//! [`rust_decimal::Decimal`]), `Bool`, `Timestamp` (unix millis) and
//! `Array` (homogeneous-or-heterogeneous nested values, indexed only as a
//! JSON blob for now). Persisted in `memory_attribute` with one
//! kind-specific column non-NULL per row, so equality / range queries are
//! O(log N) under the right partial index.
//!
//! Range queries are restricted to `Int | Decimal | Timestamp`; calls with
//! a non-orderable kind (or kind mismatch between `min` and `max`) return
//! [`crate::error::Error::InvalidAttribute`].
//!
//! See spec § 12.18 (Plan 11).

use std::cmp::Ordering;

use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

/// One typed attribute value attached to a memory.
///
/// Round-trips through serde-JSON via the externally-tagged shape
/// `{"kind": "...", "value": ...}`. Equality (`Eq`) holds for every variant;
/// hashing is *not* implemented because [`Decimal`] does not derive `Hash`.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum AttributeValue {
    /// Free-form text.
    String(String),
    /// 64-bit signed integer.
    Int(i64),
    /// Exact decimal (no f64 rounding).
    Decimal(Decimal),
    /// Boolean.
    Bool(bool),
    /// Unix epoch milliseconds — distinct from `Int` so range queries can
    /// be type-checked.
    Timestamp(i64),
    /// Nested array. Only JSON-blob indexed in this plan; deeper indexing
    /// is a follow-up.
    Array(Vec<AttributeValue>),
}

// `Decimal` implements `Eq` (it's a fixed-precision integer-pair
// representation) so a manual `Eq` impl over the whole enum is sound.
impl Eq for AttributeValue {}

impl AttributeValue {
    /// Stable string tag persisted in `memory_attribute.kind`.
    #[must_use]
    pub fn kind_str(&self) -> &'static str {
        match self {
            AttributeValue::String(_) => "string",
            AttributeValue::Int(_) => "int",
            AttributeValue::Decimal(_) => "decimal",
            AttributeValue::Bool(_) => "bool",
            AttributeValue::Timestamp(_) => "timestamp",
            AttributeValue::Array(_) => "array",
        }
    }

    /// Whether this variant supports range queries (`Int`, `Decimal`,
    /// `Timestamp`).
    #[must_use]
    pub fn is_orderable(&self) -> bool {
        matches!(
            self,
            AttributeValue::Int(_) | AttributeValue::Decimal(_) | AttributeValue::Timestamp(_)
        )
    }

    /// Total order over comparable values; returns `None` when the kinds
    /// differ or either is non-orderable.
    #[must_use]
    pub fn try_cmp(&self, other: &AttributeValue) -> Option<Ordering> {
        match (self, other) {
            (AttributeValue::Int(a), AttributeValue::Int(b)) => Some(a.cmp(b)),
            (AttributeValue::Timestamp(a), AttributeValue::Timestamp(b)) => Some(a.cmp(b)),
            (AttributeValue::Decimal(a), AttributeValue::Decimal(b)) => a.partial_cmp(b),
            _ => None,
        }
    }
}

/// Stable string tag — module-level helper for places that prefer a free
/// function form (e.g. SQL bind sites).
#[must_use]
pub fn kind_str(v: &AttributeValue) -> &'static str {
    v.kind_str()
}

impl std::fmt::Display for AttributeValue {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AttributeValue::String(s) => f.write_str(s),
            AttributeValue::Int(n) => write!(f, "{n}"),
            AttributeValue::Decimal(d) => write!(f, "{d}"),
            AttributeValue::Bool(b) => write!(f, "{b}"),
            AttributeValue::Timestamp(n) => write!(f, "{n}ms"),
            AttributeValue::Array(_) => match serde_json::to_string(self) {
                Ok(s) => f.write_str(&s),
                Err(_) => f.write_str("<array>"),
            },
        }
    }
}

impl From<&str> for AttributeValue {
    fn from(s: &str) -> Self {
        AttributeValue::String(s.to_string())
    }
}

impl From<String> for AttributeValue {
    fn from(s: String) -> Self {
        AttributeValue::String(s)
    }
}

impl From<i64> for AttributeValue {
    fn from(n: i64) -> Self {
        AttributeValue::Int(n)
    }
}

impl From<i32> for AttributeValue {
    fn from(n: i32) -> Self {
        AttributeValue::Int(i64::from(n))
    }
}

impl From<bool> for AttributeValue {
    fn from(b: bool) -> Self {
        AttributeValue::Bool(b)
    }
}

impl From<Decimal> for AttributeValue {
    fn from(d: Decimal) -> Self {
        AttributeValue::Decimal(d)
    }
}

impl<T> From<Vec<T>> for AttributeValue
where
    T: Into<AttributeValue>,
{
    fn from(v: Vec<T>) -> Self {
        AttributeValue::Array(v.into_iter().map(Into::into).collect())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal::prelude::FromStr;

    #[test]
    fn kind_strings_are_stable() {
        assert_eq!(AttributeValue::String("x".into()).kind_str(), "string");
        assert_eq!(AttributeValue::Int(0).kind_str(), "int");
        assert_eq!(
            AttributeValue::Decimal(Decimal::from(1)).kind_str(),
            "decimal"
        );
        assert_eq!(AttributeValue::Bool(true).kind_str(), "bool");
        assert_eq!(AttributeValue::Timestamp(0).kind_str(), "timestamp");
        assert_eq!(AttributeValue::Array(vec![]).kind_str(), "array");
        assert_eq!(kind_str(&AttributeValue::Bool(false)), "bool");
    }

    #[test]
    fn try_cmp_orders_int_decimal_timestamp() {
        let a = AttributeValue::Int(1);
        let b = AttributeValue::Int(2);
        assert_eq!(a.try_cmp(&b), Some(Ordering::Less));

        let a = AttributeValue::Timestamp(100);
        let b = AttributeValue::Timestamp(50);
        assert_eq!(a.try_cmp(&b), Some(Ordering::Greater));

        let a = AttributeValue::Decimal(Decimal::from_str("1.5").unwrap());
        let b = AttributeValue::Decimal(Decimal::from_str("1.5").unwrap());
        assert_eq!(a.try_cmp(&b), Some(Ordering::Equal));
    }

    #[test]
    fn try_cmp_returns_none_on_kind_mismatch() {
        let a = AttributeValue::Int(1);
        let b = AttributeValue::Timestamp(1);
        assert_eq!(a.try_cmp(&b), None);
    }

    #[test]
    fn try_cmp_returns_none_for_string_array_bool() {
        let a = AttributeValue::String("x".into());
        let b = AttributeValue::String("y".into());
        assert_eq!(a.try_cmp(&b), None);

        let a = AttributeValue::Bool(false);
        let b = AttributeValue::Bool(true);
        assert_eq!(a.try_cmp(&b), None);

        let a = AttributeValue::Array(vec![]);
        let b = AttributeValue::Array(vec![]);
        assert_eq!(a.try_cmp(&b), None);
    }

    #[test]
    fn round_trips_through_serde_json() {
        for v in [
            AttributeValue::String("hello".into()),
            AttributeValue::Int(-42),
            AttributeValue::Decimal(Decimal::from_str("12345.6789").unwrap()),
            AttributeValue::Bool(true),
            AttributeValue::Timestamp(1_700_000_000_000),
            AttributeValue::Array(vec![
                AttributeValue::Int(1),
                AttributeValue::String("two".into()),
            ]),
        ] {
            let s = serde_json::to_string(&v).unwrap();
            let back: AttributeValue = serde_json::from_str(&s).unwrap();
            assert_eq!(v, back, "round-trip failed for {s}");
        }
    }

    #[test]
    fn from_into_conversions_compile() {
        let _: AttributeValue = "x".into();
        let _: AttributeValue = String::from("x").into();
        let _: AttributeValue = 1_i64.into();
        let _: AttributeValue = 1_i32.into();
        let _: AttributeValue = true.into();
        let _: AttributeValue = Decimal::from(1).into();
        // Vec<T> where T: Into<AttributeValue> auto-converts.
        let v: AttributeValue = vec![1_i64, 2, 3].into();
        assert_eq!(v.kind_str(), "array");
        let v: AttributeValue = vec!["a", "b"].into();
        match v {
            AttributeValue::Array(items) => assert_eq!(items.len(), 2),
            _ => panic!("expected Array"),
        }
    }

    #[test]
    fn is_orderable_matches_try_cmp() {
        let cases = [
            (AttributeValue::Int(0), true),
            (AttributeValue::Decimal(Decimal::from(0)), true),
            (AttributeValue::Timestamp(0), true),
            (AttributeValue::Bool(true), false),
            (AttributeValue::String("x".into()), false),
            (AttributeValue::Array(vec![]), false),
        ];
        for (v, want) in cases {
            assert_eq!(v.is_orderable(), want, "{:?}", v);
        }
    }

    #[test]
    fn display_renders_each_kind() {
        assert_eq!(AttributeValue::String("x".into()).to_string(), "x");
        assert_eq!(AttributeValue::Int(42).to_string(), "42");
        assert_eq!(AttributeValue::Bool(true).to_string(), "true");
        assert_eq!(AttributeValue::Timestamp(123).to_string(), "123ms");
        assert_eq!(
            AttributeValue::Decimal(Decimal::from_str("1.5").unwrap()).to_string(),
            "1.5"
        );
        let arr = AttributeValue::Array(vec![AttributeValue::Int(1)]);
        assert!(arr.to_string().contains("\"int\""));
    }
}