Skip to main content

recall_echo/graph/
types.rs

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