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}