klieo-memory-graph 3.4.0

KnowledgeGraph trait surface + InMemoryGraph for klieo. Stable at 1.x per ADR-039 trait freeze.
Documentation
//! Entity type system for graph indexing.
//!
//! `#[non_exhaustive]` lets `EntityType` add variants in a 0.x minor without
//! breaking external matches. **Internal** match in `as_str()` is exhaustive
//! (no `_` arm) — the compiler enforces an update inside this crate when a
//! new variant lands. See ADR-035 for the carve-out rationale.

use chrono::{DateTime, TimeZone, Utc};
use klieo_core::error::MemoryError;
use klieo_core::ids::FactId;
use serde::{Deserialize, Serialize};

/// Reserved typed-variant labels that `EntityType::Other { hint }` must NOT
/// shadow.
const RESERVED_ENTITY_LABELS: &[&str] = &[
    "Ticket",
    "Policy",
    "Agent",
    "Member",
    "Concept",
    "Article",
    "Obligation",
    "Deadline",
];

/// Far-past sentinel for knowledge that has always been valid.
///
/// Pass this as `valid_from` for regulatory texts, baseline facts, etc.
/// Prefer this over `1970-01-01` literals scattered throughout call sites.
pub fn known_epoch() -> DateTime<Utc> {
    Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
        .single()
        .expect("Unix epoch is a valid UTC datetime")
}

/// Classification of a named entity for graph indexing.
///
/// `#[non_exhaustive]` for external consumers — additive variants are
/// backwards-compatible. Internal exhaustive match in `as_str()` ensures
/// any new variant added inside this crate forces an `as_str()` update.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EntityType {
    /// Issue or ticket (e.g. `TICKET-123`, `POL-42`).
    Ticket,
    /// Business or insurance policy.
    Policy,
    /// Agent in the klieo runtime.
    Agent,
    /// Human member or customer.
    Member,
    /// Domain concept not fitting typed variants.
    Concept,
    /// Regulatory text article (e.g. `DORA-Art-5`, `AI-Act-Art-13`).
    /// Use with `valid_from = known_epoch()` for all-time regulatory facts.
    Article,
    /// Compliance obligation derived from a regulatory `Article`.
    Obligation,
    /// Time-bound regulatory commitment (e.g. `DORA-Compliance-2026-01-17`).
    Deadline,
    /// Escape hatch for novel domains. `hint` is the raw label from the source.
    ///
    /// **Constraint:** `hint` MUST NOT equal any label in
    /// `RESERVED_ENTITY_LABELS` — `as_str()` would otherwise collide
    /// with the typed variant and silent-merge in the entity index
    /// keyed on `(as_str(), name, scope)`. Callers SHOULD use the
    /// typed variant directly; [`EntityRef::try_new`] rejects
    /// violating hints via `MemoryError::Store`.
    Other {
        /// Caller-supplied label used as the Neo4j `:Entity.type` value.
        hint: String,
    },
}

impl EntityType {
    /// Stable string label used as the Neo4j `:Entity` `type` property.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Ticket => "Ticket",
            Self::Policy => "Policy",
            Self::Agent => "Agent",
            Self::Member => "Member",
            Self::Concept => "Concept",
            Self::Article => "Article",
            Self::Obligation => "Obligation",
            Self::Deadline => "Deadline",
            Self::Other { hint } => hint.as_str(),
        }
    }

    /// Inverse of [`Self::as_str`] — resolve a stored label to its typed
    /// variant. Unrecognised labels round-trip through `Other { hint }`.
    ///
    /// Single authoritative dispatch for string → enum so adapters
    /// (LLM extractor, Neo4j deserialiser, future SQL backends) all
    /// agree by construction. Adding a new typed variant becomes one
    /// change in this match plus the `as_str` arm — adapters delegate.
    pub fn from_label(label: &str) -> Self {
        match label {
            "Ticket" => Self::Ticket,
            "Policy" => Self::Policy,
            "Agent" => Self::Agent,
            "Member" => Self::Member,
            "Concept" => Self::Concept,
            "Article" => Self::Article,
            "Obligation" => Self::Obligation,
            "Deadline" => Self::Deadline,
            other => Self::Other {
                hint: other.to_string(),
            },
        }
    }
}

/// A typed, normalised entity reference.
///
/// `#[non_exhaustive]` so future fields (e.g. confidence, source span) can
/// land in a 0.x minor without breaking struct-literal callers — construct
/// via [`EntityRef::try_new`] or [`EntityRef::new`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct EntityRef {
    /// Entity classification.
    pub entity_type: EntityType,
    /// Normalised (lowercase) entity name.
    pub name: String,
}

impl EntityRef {
    /// Returns `Err(MemoryError::Store)` when `entity_type` is
    /// `EntityType::Other { hint }` and `hint` collides with a reserved
    /// typed-variant label — those would otherwise silently merge into the
    /// typed entity bucket via `as_str()`. Use `try_new` for caller-supplied
    /// or untrusted entity types (e.g. LLM extractor output); use
    /// [`Self::new`] only when the entity type is trusted internal code.
    pub fn try_new(entity_type: EntityType, name: impl Into<String>) -> Result<Self, MemoryError> {
        if let EntityType::Other { hint } = &entity_type {
            if RESERVED_ENTITY_LABELS.iter().any(|label| label == hint) {
                return Err(MemoryError::Store(format!(
                    "EntityType::Other {{ hint: {hint:?} }} collides with reserved typed label; use the typed variant"
                )));
            }
        }
        Ok(Self {
            entity_type,
            name: name.into().to_ascii_lowercase(),
        })
    }

    /// Panics if `EntityType::Other { hint }` collides with a reserved
    /// typed-variant label. Prefer [`Self::try_new`] for caller-supplied
    /// or untrusted entity types (e.g. LLM extractor output).
    pub fn new(entity_type: EntityType, name: impl Into<String>) -> Self {
        Self::try_new(entity_type, name).expect("EntityRef::new violated reserved-label constraint")
    }
}

/// A node in a browsable [`crate::GraphView`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GraphNode {
    /// An entity node, keyed by its normalised ref.
    Entity(EntityRef),
    /// A stored-fact reference node.
    Fact(FactId),
}

/// Relationship type of a [`GraphEdge`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EdgeKind {
    /// Entity → fact `MENTIONED_IN` edge.
    MentionedIn,
    /// Entity ↔ entity `CO_OCCURS` edge.
    CoOccurs,
}

/// An edge in a browsable [`crate::GraphView`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GraphEdge {
    #[allow(missing_docs)]
    pub from: GraphNode,
    #[allow(missing_docs)]
    pub to: GraphNode,
    #[allow(missing_docs)]
    pub kind: EdgeKind,
}

impl GraphEdge {
    /// Required because `#[non_exhaustive]` blocks external struct-literal construction.
    pub fn new(from: GraphNode, to: GraphNode, kind: EdgeKind) -> Self {
        Self { from, to, kind }
    }
}

/// A bounded snapshot of one scope's knowledge graph for browsing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GraphView {
    #[allow(missing_docs)]
    pub nodes: Vec<GraphNode>,
    #[allow(missing_docs)]
    pub edges: Vec<GraphEdge>,
    /// `true` when the scope held more nodes than `limit` allowed.
    pub truncated: bool,
    /// Sentence text for the [`GraphNode::Fact`] nodes in this view, keyed by
    /// fact id. A sidecar rather than a field on the enum so `GraphNode`
    /// stays a stable identity (equality/dedup unaffected). May omit a fact
    /// when the backend has no stored text for it (e.g. legacy Neo4j rows).
    #[serde(default)]
    pub fact_texts: std::collections::HashMap<FactId, String>,
}

impl GraphView {
    /// A complete view — every node in the scope fit within the limit.
    /// (`#[non_exhaustive]` blocks external struct-literal construction, hence the constructors.)
    pub fn complete(nodes: Vec<GraphNode>, edges: Vec<GraphEdge>) -> Self {
        Self {
            nodes,
            edges,
            truncated: false,
            fact_texts: std::collections::HashMap::new(),
        }
    }

    /// A truncated view — the scope held more nodes than the limit allowed.
    pub fn truncated(nodes: Vec<GraphNode>, edges: Vec<GraphEdge>) -> Self {
        Self {
            nodes,
            edges,
            truncated: true,
            fact_texts: std::collections::HashMap::new(),
        }
    }

    /// Attach fact-sentence text (keyed by fact id) for this view's
    /// [`GraphNode::Fact`] nodes. Chains onto either constructor.
    pub fn with_fact_texts(
        mut self,
        fact_texts: std::collections::HashMap<FactId, String>,
    ) -> Self {
        self.fact_texts = fact_texts;
        self
    }
}

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

    #[test]
    fn from_label_covers_every_reserved_label() {
        // Future variants added to RESERVED_ENTITY_LABELS must also
        // be added to from_label, or they silently fall through to
        // Other { hint } and break the round-trip invariant. This
        // test fails on drift between the two.
        for label in RESERVED_ENTITY_LABELS {
            let resolved = EntityType::from_label(label);
            assert!(
                !matches!(resolved, EntityType::Other { .. }),
                "reserved label {label:?} routed to Other — \
                 add an arm to EntityType::from_label",
            );
            assert_eq!(resolved.as_str(), *label);
        }
    }
}