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/// A knowledge-graph triple prepared for bulk import — the wire shape
17/// shared by `ijima import` (source dbs) and the HTTP client
18/// (`import_kg`). Subject/object are entity **names** (Ijima's
19/// id-is-name convention); `valid_to` is applied as a post-add
20/// invalidation when present.
21#[derive(Debug, Clone, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct ImportTriple {
24    /// Subject entity **name** (becomes the `EntityId`).
25    pub subject: String,
26    /// The relationship verb (e.g. `depends_on`).
27    pub predicate: String,
28    /// Object entity **name** (becomes the `EntityId`).
29    pub object: String,
30    /// When the fact became true (ISO date), if known.
31    pub valid_from: Option<String>,
32    /// When the fact stopped being true; applied as a post-add
33    /// invalidation on import.
34    pub valid_to: Option<String>,
35    /// Source confidence `[0, 1]`.
36    pub confidence: f32,
37    /// The memory that evidenced the fact, if any.
38    pub source_memory_id: Option<String>,
39}
40
41/// Aggregate counts reported by a knowledge-graph import run.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct KgImportCounts {
45    /// Triples read from the source.
46    pub attempted: usize,
47    /// Successfully added (including invalidations for historical
48    /// ranges).
49    pub added: usize,
50    /// Not added — transport or store failure per-triple.
51    pub skipped: usize,
52}
53
54/// Stable opaque identifier for an entity (node).
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57#[cfg_attr(feature = "serde", serde(transparent))]
58pub struct EntityId(pub String);
59
60impl EntityId {
61    /// Construct an entity id.
62    #[must_use]
63    pub fn new(id: impl Into<String>) -> Self {
64        Self(id.into())
65    }
66
67    /// The stable wire string.
68    #[must_use]
69    pub fn as_str(&self) -> &str {
70        &self.0
71    }
72}
73
74/// An entity — a node in the knowledge graph.
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct Entity {
78    /// Stable opaque identifier (e.g. `Quantizon`, `candle`).
79    pub id: EntityId,
80    /// Human-readable display name.
81    pub name: String,
82    /// Semantic type (`"project"`, `"person"`, `"tool"`, `"unknown"`...).
83    pub entity_type: String,
84    /// Owning namespace (isolation).
85    pub namespace: String,
86}
87
88/// A temporal triple: subject -predicate-> object with validity. The
89/// `valid_to` field expresses invalidation (`None` = still current).
90#[derive(Debug, Clone, PartialEq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub struct Triple {
93    /// The edge record id (backend-assigned).
94    pub id: String,
95    /// The source entity.
96    pub subject: EntityId,
97    /// The relationship (`"depends_on"`, `"uses"`, `"decided"`...).
98    pub predicate: String,
99    /// The target entity.
100    pub object: EntityId,
101    /// When the fact became true (epoch-secs string).
102    pub valid_from: Option<String>,
103    /// When it stopped being true (`None` = still current).
104    pub valid_to: Option<String>,
105    /// Confidence score (0.0–1.0).
106    pub confidence: f32,
107    /// Owning namespace.
108    pub namespace: String,
109    /// The memory this fact was extracted from, when known.
110    pub source_memory_id: Option<String>,
111}
112
113/// An entity plus its connected triples (query result).
114#[derive(Debug, Clone, PartialEq)]
115#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
116pub struct EntityRecord {
117    /// The entity node, if it exists.
118    pub entity: Option<Entity>,
119    /// Triples where this entity is the subject (outgoing).
120    pub outgoing: Vec<Triple>,
121    /// Triples where this entity is the object (incoming).
122    pub incoming: Vec<Triple>,
123}
124
125/// Counts for the knowledge-graph stats endpoint.
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128pub struct KgStats {
129    /// Number of entity nodes.
130    pub entities: usize,
131    /// Number of triple edges.
132    pub triples: usize,
133}
134
135/// The knowledge-graph contract every backend implements alongside
136/// [`crate::Store`]. All methods are scoped to a [`NamespaceId`].
137#[async_trait]
138pub trait KnowledgeGraph: Send + Sync {
139    /// Adds (or refreshes) the subject + object entities and creates a
140    /// triple edge between them. Returns the created [`Triple`].
141    #[allow(clippy::too_many_arguments)]
142    async fn add_triple(
143        &self,
144        ns: &NamespaceId,
145        subject: EntityId,
146        predicate: &str,
147        object: EntityId,
148        valid_from: Option<&str>,
149        confidence: f32,
150        source_memory_id: Option<&str>,
151    ) -> Result<Triple>;
152
153    /// Returns an entity and all its connected triples (outgoing +
154    /// incoming) within `ns`.
155    async fn query_entity(&self, ns: &NamespaceId, entity: &EntityId) -> Result<EntityRecord>;
156
157    /// Marks a triple as no longer current by setting `valid_to`.
158    /// Idempotent.
159    async fn invalidate_triple(&self, ns: &NamespaceId, triple_id: &str) -> Result<()>;
160
161    /// Finds triples matching any combination of subject / predicate /
162    /// object (`None` = wildcard).
163    async fn find_triples(
164        &self,
165        ns: &NamespaceId,
166        subject: Option<&EntityId>,
167        predicate: Option<&str>,
168        object: Option<&EntityId>,
169    ) -> Result<Vec<Triple>>;
170
171    /// Returns triples in chronological order (by `valid_from`), most
172    /// recent first.
173    async fn kg_timeline(&self, ns: &NamespaceId, limit: usize) -> Result<Vec<Triple>>;
174
175    /// Entity + triple counts for `ns`.
176    async fn knowledge_stats(&self, ns: &NamespaceId) -> Result<KgStats>;
177
178    /// Global entity + triple counts across all namespaces.
179    async fn kg_global_stats(&self) -> Result<KgStats>;
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn entity_id_round_trips() {
188        let id = EntityId::new("Quantizon");
189        assert_eq!(id.as_str(), "Quantizon");
190        assert_eq!(id, EntityId("Quantizon".into()));
191    }
192
193    #[test]
194    fn kg_stats_defaults_to_zero() {
195        let s = KgStats::default();
196        assert_eq!(s.entities, 0);
197        assert_eq!(s.triples, 0);
198    }
199}