Skip to main content

recall_echo/graph/
types.rs

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