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}