Skip to main content

ijima_core/
knowledge.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Knowledge graph domain types โ€” entities (nodes) + temporal triples
5//! (edges). The structured-facts layer: *"Who depends on Y?" "What did
6//! we decide about X?"*
7//!
8//! Import-compatible with the pi-mempalace `entities` + `triples` schema
9//! (see `docs/HANDOFF.md` ยง3). In the SurrealDB backend, entities are
10//! record nodes and triples are graph edges (`RELATE ... ->triples->`).
11
12use async_trait::async_trait;
13
14use crate::{NamespaceId, Result};
15
16/// Stable opaque identifier for an entity (node).
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(transparent))]
20pub struct EntityId(pub String);
21
22impl EntityId {
23    /// Construct an entity id.
24    #[must_use]
25    pub fn new(id: impl Into<String>) -> Self {
26        Self(id.into())
27    }
28
29    /// The stable wire string.
30    #[must_use]
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36/// An entity โ€” a node in the knowledge graph.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct Entity {
40    /// Stable opaque identifier (e.g. `Quantizon`, `candle`).
41    pub id: EntityId,
42    /// Human-readable display name.
43    pub name: String,
44    /// Semantic type (`"project"`, `"person"`, `"tool"`, `"unknown"`...).
45    pub entity_type: String,
46    /// Owning namespace (isolation).
47    pub namespace: String,
48}
49
50/// A temporal triple: subject -predicate-> object with validity. The
51/// `valid_to` field expresses invalidation (`None` = still current).
52#[derive(Debug, Clone, PartialEq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54pub struct Triple {
55    /// The edge record id (backend-assigned).
56    pub id: String,
57    /// The source entity.
58    pub subject: EntityId,
59    /// The relationship (`"depends_on"`, `"uses"`, `"decided"`...).
60    pub predicate: String,
61    /// The target entity.
62    pub object: EntityId,
63    /// When the fact became true (epoch-secs string).
64    pub valid_from: Option<String>,
65    /// When it stopped being true (`None` = still current).
66    pub valid_to: Option<String>,
67    /// Confidence score (0.0โ€“1.0).
68    pub confidence: f32,
69    /// Owning namespace.
70    pub namespace: String,
71    /// The memory this fact was extracted from, when known.
72    pub source_memory_id: Option<String>,
73}
74
75/// An entity plus its connected triples (query result).
76#[derive(Debug, Clone, PartialEq)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct EntityRecord {
79    /// The entity node, if it exists.
80    pub entity: Option<Entity>,
81    /// Triples where this entity is the subject (outgoing).
82    pub outgoing: Vec<Triple>,
83    /// Triples where this entity is the object (incoming).
84    pub incoming: Vec<Triple>,
85}
86
87/// Counts for the knowledge-graph stats endpoint.
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub struct KgStats {
91    /// Number of entity nodes.
92    pub entities: usize,
93    /// Number of triple edges.
94    pub triples: usize,
95}
96
97/// The knowledge-graph contract every backend implements alongside
98/// [`crate::Store`]. All methods are scoped to a [`NamespaceId`].
99#[async_trait]
100pub trait KnowledgeGraph: Send + Sync {
101    /// Adds (or refreshes) the subject + object entities and creates a
102    /// triple edge between them. Returns the created [`Triple`].
103    #[allow(clippy::too_many_arguments)]
104    async fn add_triple(
105        &self,
106        ns: &NamespaceId,
107        subject: EntityId,
108        predicate: &str,
109        object: EntityId,
110        valid_from: Option<&str>,
111        confidence: f32,
112        source_memory_id: Option<&str>,
113    ) -> Result<Triple>;
114
115    /// Returns an entity and all its connected triples (outgoing +
116    /// incoming) within `ns`.
117    async fn query_entity(&self, ns: &NamespaceId, entity: &EntityId) -> Result<EntityRecord>;
118
119    /// Marks a triple as no longer current by setting `valid_to`.
120    /// Idempotent.
121    async fn invalidate_triple(&self, ns: &NamespaceId, triple_id: &str) -> Result<()>;
122
123    /// Finds triples matching any combination of subject / predicate /
124    /// object (`None` = wildcard).
125    async fn find_triples(
126        &self,
127        ns: &NamespaceId,
128        subject: Option<&EntityId>,
129        predicate: Option<&str>,
130        object: Option<&EntityId>,
131    ) -> Result<Vec<Triple>>;
132
133    /// Returns triples in chronological order (by `valid_from`), most
134    /// recent first.
135    async fn kg_timeline(&self, ns: &NamespaceId, limit: usize) -> Result<Vec<Triple>>;
136
137    /// Entity + triple counts for `ns`.
138    async fn knowledge_stats(&self, ns: &NamespaceId) -> Result<KgStats>;
139
140    /// Global entity + triple counts across all namespaces.
141    async fn kg_global_stats(&self) -> Result<KgStats>;
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn entity_id_round_trips() {
150        let id = EntityId::new("Quantizon");
151        assert_eq!(id.as_str(), "Quantizon");
152        assert_eq!(id, EntityId("Quantizon".into()));
153    }
154
155    #[test]
156    fn kg_stats_defaults_to_zero() {
157        let s = KgStats::default();
158        assert_eq!(s.entities, 0);
159        assert_eq!(s.triples, 0);
160    }
161}