Skip to main content

recall_echo/graph/
types.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6/// Node types in the knowledge graph.
7/// Mutable types can be merged/updated. Immutable types are historical facts.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "snake_case")]
10pub enum EntityType {
11    Person,
12    Project,
13    Tool,
14    Service,
15    Preference,
16    Decision,
17    Event,
18    Concept,
19    Case,
20    Pattern,
21    Thread,
22    Thought,
23    Question,
24    Observation,
25    Policy,
26    Measurement,
27    Outcome,
28}
29
30impl EntityType {
31    #[must_use]
32    pub fn is_mutable(&self) -> bool {
33        !matches!(
34            self,
35            Self::Decision
36                | Self::Event
37                | Self::Case
38                | Self::Observation
39                | Self::Measurement
40                | Self::Outcome
41        )
42    }
43}
44
45impl fmt::Display for EntityType {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        let s = serde_json::to_value(self)
48            .ok()
49            .and_then(|v| v.as_str().map(String::from))
50            .unwrap_or_else(|| format!("{self:?}"));
51        write!(f, "{s}")
52    }
53}
54
55impl std::str::FromStr for EntityType {
56    type Err = String;
57
58    fn from_str(s: &str) -> Result<Self, Self::Err> {
59        serde_json::from_value(serde_json::Value::String(s.to_string()))
60            .map_err(|_| format!("unknown entity type: {s}"))
61    }
62}
63
64/// Input for creating a new entity.
65#[derive(Debug, Clone)]
66pub struct NewEntity {
67    pub name: String,
68    pub entity_type: EntityType,
69    pub abstract_text: String,
70    pub overview: Option<String>,
71    pub content: Option<String>,
72    pub attributes: Option<serde_json::Value>,
73    pub source: Option<String>,
74}
75
76/// A stored entity with all fields.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Entity {
79    pub id: serde_json::Value,
80    pub name: String,
81    pub entity_type: EntityType,
82    #[serde(rename = "abstract")]
83    pub abstract_text: String,
84    pub overview: String,
85    pub content: Option<String>,
86    pub attributes: Option<serde_json::Value>,
87    /// Omitted when absent: an entity crossing the daemon socket carries no
88    /// embedding, and 384 floats of JSON text per entity is pure overhead.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub embedding: Option<Vec<f32>>,
91    #[serde(default = "default_true")]
92    pub mutable: bool,
93    #[serde(default)]
94    pub access_count: i64,
95    /// How useful this entity has been across sessions (0.0-1.0, default 0.5 neutral).
96    #[serde(default = "default_utility_score")]
97    pub utility_score: f64,
98    /// Number of times utility_score has been updated via outcome feedback.
99    #[serde(default)]
100    pub utility_updates: i64,
101    pub created_at: serde_json::Value,
102    pub updated_at: serde_json::Value,
103    pub source: Option<String>,
104}
105
106impl Entity {
107    /// Get the record ID as a string (e.g. "entity:abc123").
108    #[must_use]
109    pub fn id_string(&self) -> String {
110        match &self.id {
111            serde_json::Value::String(s) => s.clone(),
112            other => other.to_string(),
113        }
114    }
115
116    /// Get the updated_at timestamp as a string.
117    #[must_use]
118    pub fn updated_at_string(&self) -> String {
119        match &self.updated_at {
120            serde_json::Value::String(s) => s.clone(),
121            other => other.to_string(),
122        }
123    }
124}
125
126fn default_true() -> bool {
127    true
128}
129
130fn default_utility_score() -> f64 {
131    0.5
132}
133
134/// Fields that can be updated on an entity.
135#[derive(Debug, Clone, Default, Serialize)]
136pub struct EntityUpdate {
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub abstract_text: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub overview: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub content: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub attributes: Option<serde_json::Value>,
145}
146
147/// Input for creating a new relationship.
148#[derive(Debug, Clone)]
149pub struct NewRelationship {
150    pub from_entity: String,
151    pub to_entity: String,
152    pub rel_type: String,
153    pub description: Option<String>,
154    pub confidence: Option<f32>,
155    pub source: Option<String>,
156}
157
158/// A stored relationship.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Relationship {
161    pub id: serde_json::Value,
162    #[serde(rename = "in")]
163    pub from_id: serde_json::Value,
164    #[serde(rename = "out")]
165    pub to_id: serde_json::Value,
166    pub rel_type: String,
167    pub description: Option<String>,
168    pub valid_from: serde_json::Value,
169    pub valid_until: Option<serde_json::Value>,
170    pub confidence: f64,
171    /// When this relationship was last reinforced (Bayesian corroboration).
172    /// Used by temporal decay: effective_confidence = confidence × 0.5^(days_since / half_life).
173    #[serde(default)]
174    pub last_reinforced: Option<serde_json::Value>,
175    pub source: Option<String>,
176}
177
178impl Relationship {
179    /// Get the record ID as a string.
180    #[must_use]
181    pub fn id_string(&self) -> String {
182        match &self.id {
183            serde_json::Value::String(s) => s.clone(),
184            other => other.to_string(),
185        }
186    }
187}
188
189/// Direction for relationship queries.
190#[derive(Debug, Clone, Copy)]
191pub enum Direction {
192    Outgoing,
193    Incoming,
194    Both,
195}
196
197// ── Tiered entity projections ────────────────────────────────────────
198
199/// L0 — Minimal entity for traversal. No embedding, no content.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct EntitySummary {
202    pub id: serde_json::Value,
203    pub name: String,
204    pub entity_type: EntityType,
205    #[serde(rename = "abstract")]
206    pub abstract_text: String,
207}
208
209impl EntitySummary {
210    #[must_use]
211    pub fn id_string(&self) -> String {
212        match &self.id {
213            serde_json::Value::String(s) => s.clone(),
214            other => other.to_string(),
215        }
216    }
217}
218
219/// L1 — Search result detail. Everything except content and embedding.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct EntityDetail {
222    pub id: serde_json::Value,
223    pub name: String,
224    pub entity_type: EntityType,
225    #[serde(rename = "abstract")]
226    pub abstract_text: String,
227    pub overview: String,
228    pub attributes: Option<serde_json::Value>,
229    #[serde(default)]
230    pub access_count: i64,
231    #[serde(default = "default_utility_score")]
232    pub utility_score: f64,
233    pub updated_at: serde_json::Value,
234    pub source: Option<String>,
235}
236
237impl EntityDetail {
238    #[must_use]
239    pub fn id_string(&self) -> String {
240        match &self.id {
241            serde_json::Value::String(s) => s.clone(),
242            other => other.to_string(),
243        }
244    }
245
246    #[must_use]
247    pub fn updated_at_string(&self) -> String {
248        match &self.updated_at {
249            serde_json::Value::String(s) => s.clone(),
250            other => other.to_string(),
251        }
252    }
253}
254
255// ── Search types ────────────────────────────────────────────────────
256
257/// Options for entity search.
258#[derive(Debug, Clone, Default)]
259pub struct SearchOptions {
260    pub limit: usize,
261    pub entity_type: Option<String>,
262    pub keyword: Option<String>,
263}
264
265/// How an entity was found.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum MatchSource {
269    /// Found via semantic similarity.
270    Semantic,
271    /// Found via graph expansion from a parent entity.
272    Graph { parent: String, rel_type: String },
273    /// Found via keyword filter match.
274    Keyword,
275}
276
277/// A scored entity in search results.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct ScoredEntity {
280    pub entity: EntityDetail,
281    pub score: f64,
282    pub source: MatchSource,
283}
284
285/// An episode search result.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct EpisodeSearchResult {
288    pub episode: Episode,
289    pub score: f64,
290    pub distance: f64,
291}
292
293/// Options for hybrid query (semantic + graph expansion + episodes).
294#[derive(Debug, Clone)]
295pub struct QueryOptions {
296    pub limit: usize,
297    pub entity_type: Option<String>,
298    pub keyword: Option<String>,
299    pub graph_depth: u32,
300    pub include_episodes: bool,
301}
302
303impl Default for QueryOptions {
304    fn default() -> Self {
305        Self {
306            limit: 10,
307            entity_type: None,
308            keyword: None,
309            graph_depth: 1,
310            include_episodes: false,
311        }
312    }
313}
314
315/// Result of a hybrid query.
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct QueryResult {
318    pub entities: Vec<ScoredEntity>,
319    pub episodes: Vec<EpisodeSearchResult>,
320}
321
322/// A search result with scoring (legacy — wraps full Entity).
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct SearchResult {
325    pub entity: Entity,
326    pub score: f64,
327    pub distance: f64,
328}
329
330/// A node in a traversal tree.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct TraversalNode {
333    pub entity: EntitySummary,
334    pub edges: Vec<TraversalEdge>,
335}
336
337/// An edge in a traversal tree.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct TraversalEdge {
340    pub rel_type: String,
341    pub direction: String,
342    pub target: TraversalNode,
343    pub valid_from: serde_json::Value,
344    pub valid_until: Option<serde_json::Value>,
345    pub confidence: f64,
346}
347
348/// A row from a relationship query (shared by traverse and query).
349#[derive(Debug, Clone, serde::Deserialize)]
350pub struct EdgeRow {
351    pub rel_type: String,
352    pub valid_from: serde_json::Value,
353    pub valid_until: Option<serde_json::Value>,
354    pub target_id: serde_json::Value,
355    #[serde(default = "default_confidence")]
356    pub confidence: f64,
357    #[serde(default)]
358    pub last_reinforced: Option<serde_json::Value>,
359}
360
361fn default_confidence() -> f64 {
362    1.0
363}
364
365impl EdgeRow {
366    #[must_use]
367    pub fn target_id_string(&self) -> String {
368        match &self.target_id {
369            serde_json::Value::String(s) => s.clone(),
370            other => other.to_string(),
371        }
372    }
373}
374
375/// Graph-level statistics.
376#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct GraphStats {
378    pub entity_count: u64,
379    pub relationship_count: u64,
380    pub episode_count: u64,
381    pub entity_type_counts: HashMap<String, u64>,
382}
383
384// ── Ingestion types (Phase 2) ────────────────────────────────────────
385
386/// Input for creating a new episode.
387#[derive(Debug, Clone)]
388pub struct NewEpisode {
389    pub session_id: String,
390    pub abstract_text: String,
391    pub overview: Option<String>,
392    pub content: Option<String>,
393    pub log_number: Option<u32>,
394}
395
396/// A stored episode.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct Episode {
399    pub id: serde_json::Value,
400    pub session_id: String,
401    pub timestamp: serde_json::Value,
402    #[serde(rename = "abstract")]
403    pub abstract_text: String,
404    pub overview: Option<String>,
405    pub content: Option<String>,
406    /// Omitted when absent — see [`Entity::embedding`].
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub embedding: Option<Vec<f32>>,
409    pub log_number: Option<i64>,
410}
411
412impl Episode {
413    #[must_use]
414    pub fn id_string(&self) -> String {
415        match &self.id {
416            serde_json::Value::String(s) => s.clone(),
417            other => other.to_string(),
418        }
419    }
420}
421
422/// A candidate entity extracted by the LLM from a conversation chunk.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ExtractedEntity {
425    pub name: String,
426    #[serde(rename = "type")]
427    pub entity_type: EntityType,
428    #[serde(rename = "abstract")]
429    pub abstract_text: String,
430    pub overview: Option<String>,
431    pub content: Option<String>,
432    pub attributes: Option<serde_json::Value>,
433}
434
435impl ExtractedEntity {
436    /// Convert this extraction result into a `NewEntity` ready for storage.
437    #[must_use]
438    pub fn to_new_entity(&self, session_id: &str) -> NewEntity {
439        NewEntity {
440            name: self.name.clone(),
441            entity_type: self.entity_type.clone(),
442            abstract_text: self.abstract_text.clone(),
443            overview: self.overview.clone(),
444            content: self.content.clone(),
445            attributes: self.attributes.clone(),
446            source: Some(session_id.to_string()),
447        }
448    }
449}
450
451/// A candidate relationship extracted by the LLM.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct ExtractedRelationship {
454    pub source: String,
455    pub target: String,
456    pub rel_type: String,
457    pub description: Option<String>,
458    #[serde(default)]
459    pub confidence: Option<String>,
460}
461
462/// An extracted case (problem-solution pair).
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct ExtractedCase {
465    pub problem: String,
466    pub solution: String,
467    pub context: Option<String>,
468}
469
470/// An extracted pattern (reusable process).
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct ExtractedPattern {
473    pub name: String,
474    pub process: String,
475    pub conditions: Option<String>,
476}
477
478/// An extracted preference (one per facet).
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct ExtractedPreference {
481    pub facet: String,
482    pub value: String,
483    pub context: Option<String>,
484}
485
486/// Full extraction result from a single conversation chunk.
487#[derive(Debug, Clone, Serialize, Deserialize, Default)]
488pub struct ExtractionResult {
489    #[serde(default)]
490    pub entities: Vec<ExtractedEntity>,
491    #[serde(default)]
492    pub relationships: Vec<ExtractedRelationship>,
493    #[serde(default)]
494    pub cases: Vec<ExtractedCase>,
495    #[serde(default)]
496    pub patterns: Vec<ExtractedPattern>,
497    #[serde(default)]
498    pub preferences: Vec<ExtractedPreference>,
499}
500
501/// LLM deduplication decision for a candidate entity.
502#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
503#[serde(rename_all = "snake_case")]
504pub enum DedupDecision {
505    Skip,
506    Create,
507    Merge { target: String },
508}
509
510// ── Pipeline types ───────────────────────────────────────────────────
511
512/// Canonical relationship types for the praxis pipeline.
513pub mod pipeline_rels {
514    pub const EVOLVED_FROM: &str = "EVOLVED_FROM";
515    pub const CRYSTALLIZED_FROM: &str = "CRYSTALLIZED_FROM";
516    pub const INFORMED_BY: &str = "INFORMED_BY";
517    pub const EXPLORES: &str = "EXPLORES";
518    pub const GRADUATED_TO: &str = "GRADUATED_TO";
519    pub const ARCHIVED_FROM: &str = "ARCHIVED_FROM";
520    pub const CONNECTED_TO: &str = "CONNECTED_TO";
521    pub const PROMPTED_BY: &str = "PROMPTED_BY";
522    pub const ANSWERED_BY: &str = "ANSWERED_BY";
523}
524
525/// Canonical relationship types for vigil-pulse data.
526pub mod vigil_rels {
527    pub const MEASURED_DURING: &str = "MEASURED_DURING";
528    pub const RESULTED_IN: &str = "RESULTED_IN";
529    pub const TRIGGERED_BY: &str = "TRIGGERED_BY";
530}
531
532/// Report from a vigil sync operation.
533#[derive(Debug, Clone, Default)]
534pub struct VigilSyncReport {
535    pub measurements_created: u32,
536    pub outcomes_created: u32,
537    pub events_created: u32,
538    pub relationships_created: u32,
539    pub skipped: u32,
540    pub errors: Vec<String>,
541}
542
543/// Contents of all 5 pipeline markdown files.
544#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
545pub struct PipelineDocuments {
546    pub learning: String,
547    pub thoughts: String,
548    pub curiosity: String,
549    pub reflections: String,
550    pub praxis: String,
551}
552
553/// Report from a pipeline sync operation.
554#[derive(Debug, Clone, Default, Serialize, Deserialize)]
555pub struct PipelineSyncReport {
556    pub entities_created: u32,
557    pub entities_updated: u32,
558    pub entities_archived: u32,
559    pub relationships_created: u32,
560    pub relationships_skipped: u32,
561    pub errors: Vec<String>,
562}
563
564/// Pipeline health stats from the graph.
565#[derive(Debug, Clone, Default)]
566pub struct PipelineGraphStats {
567    pub by_stage: HashMap<String, HashMap<String, u64>>,
568    pub stale_thoughts: Vec<EntityDetail>,
569    pub stale_questions: Vec<EntityDetail>,
570    pub orphan_count: u64,
571    pub total_entities: u64,
572    pub last_movement: Option<String>,
573}
574
575/// A parsed pipeline entry from a markdown document.
576#[derive(Debug, Clone)]
577pub struct PipelineEntry {
578    /// Title from ### heading (cleaned of dates and markers).
579    pub title: String,
580    /// Full content under the heading.
581    pub body: String,
582    /// Status: "active", "graduated", "dissolved", "explored", "retired".
583    pub status: String,
584    /// Stage: "learning", "thoughts", "curiosity", "reflections", "praxis".
585    pub stage: String,
586    /// Mapped entity type.
587    pub entity_type: EntityType,
588    /// Date from heading or metadata field.
589    pub date: Option<String>,
590    /// **Source:** field value.
591    pub source_ref: Option<String>,
592    /// **Destination:** field value.
593    pub destination: Option<String>,
594    /// Parsed "Connected to:" references.
595    pub connected_to: Vec<String>,
596    /// Sub-type for special sections: "theme", "pattern", "phronesis".
597    pub sub_type: Option<String>,
598}
599
600/// Result of a full ingestion run.
601#[derive(Debug, Clone, Default, Serialize, Deserialize)]
602pub struct IngestionReport {
603    pub episodes_created: u32,
604    pub entities_created: u32,
605    pub entities_merged: u32,
606    pub entities_skipped: u32,
607    pub relationships_created: u32,
608    pub relationships_skipped: u32,
609    pub errors: Vec<String>,
610    pub estimated_tokens: u64,
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    fn episode_with(embedding: Option<Vec<f32>>) -> Episode {
618        Episode {
619            id: serde_json::json!("episode:one"),
620            session_id: "s1".into(),
621            timestamp: serde_json::json!("2026-01-01T00:00:00Z"),
622            abstract_text: "a".into(),
623            overview: None,
624            content: None,
625            embedding,
626            log_number: Some(1),
627        }
628    }
629
630    /// Embeddings are 384 floats each. They exist to be searched inside the
631    /// store, never to be shipped to a caller that discards them.
632    #[test]
633    fn an_episode_without_an_embedding_carries_no_embedding_field() {
634        let json = serde_json::to_value(episode_with(None)).unwrap();
635        assert!(json.get("embedding").is_none(), "{json}");
636
637        let json = serde_json::to_value(episode_with(Some(vec![0.5; 384]))).unwrap();
638        assert!(json.get("embedding").is_some(), "{json}");
639    }
640
641    #[test]
642    fn an_episode_round_trips_without_its_embedding() {
643        let line = serde_json::to_string(&episode_with(None)).unwrap();
644        let parsed: Episode = serde_json::from_str(&line).unwrap();
645        assert!(parsed.embedding.is_none());
646        assert_eq!(parsed.session_id, "s1");
647    }
648
649    #[test]
650    fn pipeline_documents_survive_the_daemon_wire_format() {
651        let docs = PipelineDocuments {
652            learning: "# learning".into(),
653            thoughts: "# thoughts".into(),
654            curiosity: String::new(),
655            reflections: "# reflections".into(),
656            praxis: String::new(),
657        };
658        let line = serde_json::to_string(&docs).unwrap();
659        assert_eq!(
660            serde_json::from_str::<PipelineDocuments>(&line).unwrap(),
661            docs
662        );
663    }
664}