Skip to main content

recall_echo/graph/
pipeline.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
5//! Pipeline document parser — converts praxis pipeline markdown documents into graph entities.
6//!
7//! Parses LEARNING.md, THOUGHTS.md, CURIOSITY.md, REFLECTIONS.md, and PRAXIS.md into
8//! `PipelineEntry` instances that can be synced to the knowledge graph.
9//!
10//! No LLM required — this is deterministic markdown parsing.
11
12use regex::Regex;
13
14use super::types::*;
15
16/// Parse a LEARNING.md file into pipeline entries.
17///
18/// Format: `## Active Threads` section with `### Title (YYYY-MM-DD)` entries.
19#[must_use]
20pub fn parse_learning(content: &str) -> Vec<PipelineEntry> {
21    let sections = split_sections(content);
22    let mut entries = Vec::new();
23
24    for (heading, body) in &sections {
25        let h = heading.to_lowercase();
26        if h.contains("active thread") {
27            let sub_entries = split_entries(body);
28            for (title, entry_body) in sub_entries {
29                let (clean_title, date) = extract_heading_date(&title);
30                entries.push(PipelineEntry {
31                    title: clean_title,
32                    body: entry_body.clone(),
33                    status: "active".into(),
34                    stage: "learning".into(),
35                    entity_type: EntityType::Thread,
36                    date,
37                    source_ref: extract_field(&entry_body, "Source"),
38                    destination: extract_field(&entry_body, "Destination"),
39                    connected_to: extract_connected_to(&entry_body),
40                    sub_type: None,
41                });
42            }
43        }
44    }
45
46    entries
47}
48
49/// Parse a THOUGHTS.md file into pipeline entries.
50///
51/// Format: `## Active`, `## Graduated`, `## Dissolved` sections with `### Title` entries.
52#[must_use]
53pub fn parse_thoughts(content: &str) -> Vec<PipelineEntry> {
54    let sections = split_sections(content);
55    let mut entries = Vec::new();
56
57    for (heading, body) in &sections {
58        let h = heading.to_lowercase();
59        let status = if h == "active" {
60            "active"
61        } else if h == "graduated" {
62            "graduated"
63        } else if h == "dissolved" {
64            "dissolved"
65        } else {
66            continue;
67        };
68
69        let sub_entries = split_entries(body);
70        for (title, entry_body) in sub_entries {
71            let clean_title = clean_thought_title(&title);
72            let date = extract_field(&entry_body, "Graduated")
73                .or_else(|| extract_field(&entry_body, "Dissolved"))
74                .or_else(|| extract_heading_date(&title).1);
75
76            entries.push(PipelineEntry {
77                title: clean_title,
78                body: entry_body.clone(),
79                status: status.into(),
80                stage: "thoughts".into(),
81                entity_type: EntityType::Thought,
82                date,
83                source_ref: extract_field(&entry_body, "Source"),
84                destination: extract_field(&entry_body, "Destination"),
85                connected_to: extract_connected_to(&entry_body),
86                sub_type: None,
87            });
88        }
89    }
90
91    entries
92}
93
94/// Parse a CURIOSITY.md file into pipeline entries.
95///
96/// Format: `## Open Questions`, `## Themes`, `## Explored` sections.
97pub fn parse_curiosity(content: &str) -> Vec<PipelineEntry> {
98    let sections = split_sections(content);
99    let mut entries = Vec::new();
100
101    for (heading, body) in &sections {
102        let h = heading.to_lowercase();
103        let (status, sub_type) = if h.contains("open question") {
104            ("active", None)
105        } else if h == "themes" {
106            ("active", Some("theme"))
107        } else if h == "explored" {
108            ("explored", None)
109        } else {
110            continue;
111        };
112
113        let sub_entries = split_entries(body);
114        for (title, entry_body) in sub_entries {
115            let date = extract_field(&entry_body, "Date explored")
116                .or_else(|| extract_heading_date(&title).1);
117
118            entries.push(PipelineEntry {
119                title: title.clone(),
120                body: entry_body.clone(),
121                status: status.into(),
122                stage: "curiosity".into(),
123                entity_type: EntityType::Question,
124                date,
125                source_ref: extract_field(&entry_body, "Source")
126                    .or_else(|| extract_field(&entry_body, "Origin")),
127                destination: None,
128                connected_to: extract_connected_to(&entry_body),
129                sub_type: sub_type.map(String::from),
130            });
131        }
132    }
133
134    entries
135}
136
137/// Parse a REFLECTIONS.md file into pipeline entries.
138///
139/// Format: `## Observations`, `## Patterns` sections with `### YYYY-MM-DD — Title` entries.
140pub fn parse_reflections(content: &str) -> Vec<PipelineEntry> {
141    let sections = split_sections(content);
142    let mut entries = Vec::new();
143
144    for (heading, body) in &sections {
145        let h = heading.to_lowercase();
146        let sub_type = if h == "observations" {
147            None
148        } else if h == "patterns" {
149            Some("pattern")
150        } else {
151            continue;
152        };
153
154        let sub_entries = split_entries(body);
155        for (title, entry_body) in sub_entries {
156            let (clean_title, date) = extract_reflection_date(&title);
157
158            entries.push(PipelineEntry {
159                title: clean_title,
160                body: entry_body.clone(),
161                status: "active".into(),
162                stage: "reflections".into(),
163                entity_type: EntityType::Observation,
164                date,
165                source_ref: extract_field(&entry_body, "Source"),
166                destination: extract_field(&entry_body, "Destination"),
167                connected_to: extract_connected_to(&entry_body),
168                sub_type: sub_type.map(String::from),
169            });
170        }
171    }
172
173    entries
174}
175
176/// Parse a PRAXIS.md file into pipeline entries.
177///
178/// Format: `## Active`, `## Documented Phronesis`, `## Retired` sections.
179pub fn parse_praxis(content: &str) -> Vec<PipelineEntry> {
180    let sections = split_sections(content);
181    let mut entries = Vec::new();
182
183    for (heading, body) in &sections {
184        let h = heading.to_lowercase();
185        let (status, sub_type) = if h == "active" {
186            ("active", None)
187        } else if h.contains("documented phronesis") || h.contains("phronesis") {
188            ("active", Some("phronesis"))
189        } else if h == "retired" {
190            ("retired", None)
191        } else {
192            continue;
193        };
194
195        let sub_entries = split_entries(body);
196        for (title, entry_body) in sub_entries {
197            let date =
198                extract_field(&entry_body, "Added").or_else(|| extract_heading_date(&title).1);
199
200            entries.push(PipelineEntry {
201                title: title.clone(),
202                body: entry_body.clone(),
203                status: status.into(),
204                stage: "praxis".into(),
205                entity_type: EntityType::Policy,
206                date,
207                source_ref: extract_field(&entry_body, "Source"),
208                destination: extract_field(&entry_body, "Destination"),
209                connected_to: extract_connected_to(&entry_body),
210                sub_type: sub_type.map(String::from),
211            });
212        }
213    }
214
215    entries
216}
217
218/// Parse all pipeline documents and return entries + inferred relationships.
219#[must_use]
220pub fn parse_all_documents(
221    docs: &PipelineDocuments,
222) -> (Vec<PipelineEntry>, Vec<ExtractedRelationship>) {
223    let mut all_entries = Vec::new();
224
225    all_entries.extend(parse_learning(&docs.learning));
226    all_entries.extend(parse_thoughts(&docs.thoughts));
227    all_entries.extend(parse_curiosity(&docs.curiosity));
228    all_entries.extend(parse_reflections(&docs.reflections));
229    all_entries.extend(parse_praxis(&docs.praxis));
230
231    let relationships = infer_relationships(&all_entries);
232
233    (all_entries, relationships)
234}
235
236/// Convert a pipeline entry into an ExtractedEntity.
237#[must_use]
238pub fn entry_to_entity(entry: &PipelineEntry) -> ExtractedEntity {
239    // Build the abstract from the first ~200 chars of body
240    let abstract_text = if entry.body.len() > 200 {
241        let end = entry
242            .body
243            .char_indices()
244            .nth(200)
245            .map(|(i, _)| i)
246            .unwrap_or(entry.body.len());
247        format!("{}...", &entry.body[..end])
248    } else {
249        entry.body.clone()
250    };
251
252    // Build attributes
253    let mut attrs = serde_json::Map::new();
254    attrs.insert(
255        "pipeline_stage".into(),
256        serde_json::Value::String(entry.stage.clone()),
257    );
258    attrs.insert(
259        "pipeline_status".into(),
260        serde_json::Value::String(entry.status.clone()),
261    );
262    if let Some(ref d) = entry.date {
263        attrs.insert("date".into(), serde_json::Value::String(d.clone()));
264    }
265    if let Some(ref s) = entry.source_ref {
266        attrs.insert("source_ref".into(), serde_json::Value::String(s.clone()));
267    }
268    if let Some(ref d) = entry.destination {
269        attrs.insert("destination".into(), serde_json::Value::String(d.clone()));
270    }
271    if let Some(ref st) = entry.sub_type {
272        attrs.insert("sub_type".into(), serde_json::Value::String(st.clone()));
273    }
274
275    ExtractedEntity {
276        name: entry.title.clone(),
277        entity_type: entry.entity_type.clone(),
278        abstract_text,
279        overview: Some(entry.body.clone()),
280        content: None,
281        attributes: Some(serde_json::Value::Object(attrs)),
282    }
283}
284
285// ── Internal helpers ─────────────────────────────────────────────────
286
287/// Split markdown content into (heading, body) pairs at `## ` boundaries.
288fn split_sections(content: &str) -> Vec<(String, String)> {
289    let mut sections = Vec::new();
290    let mut current_heading = String::new();
291    let mut current_body = String::new();
292
293    for line in content.lines() {
294        if let Some(h) = line.strip_prefix("## ") {
295            if !current_heading.is_empty() {
296                sections.push((current_heading.clone(), current_body.trim().to_string()));
297            }
298            current_heading = h.trim().to_string();
299            current_body.clear();
300        } else if !current_heading.is_empty() {
301            current_body.push_str(line);
302            current_body.push('\n');
303        }
304    }
305
306    if !current_heading.is_empty() {
307        sections.push((current_heading, current_body.trim().to_string()));
308    }
309
310    sections
311}
312
313/// Split section body into (title, body) pairs at `### ` boundaries.
314fn split_entries(content: &str) -> Vec<(String, String)> {
315    let mut entries = Vec::new();
316    let mut current_title = String::new();
317    let mut current_body = String::new();
318
319    for line in content.lines() {
320        if let Some(h) = line.strip_prefix("### ") {
321            if !current_title.is_empty() {
322                entries.push((current_title.clone(), current_body.trim().to_string()));
323            }
324            current_title = h.trim().to_string();
325            current_body.clear();
326        } else if !current_title.is_empty() {
327            current_body.push_str(line);
328            current_body.push('\n');
329        }
330    }
331
332    if !current_title.is_empty() {
333        entries.push((current_title, current_body.trim().to_string()));
334    }
335
336    entries
337}
338
339/// Extract date from heading like `### Title (YYYY-MM-DD)`.
340fn extract_heading_date(title: &str) -> (String, Option<String>) {
341    let re = Regex::new(r"\((\d{4}-\d{2}-\d{2})\)\s*$").unwrap();
342    if let Some(caps) = re.captures(title) {
343        let date = caps[1].to_string();
344        let clean = re.replace(title, "").trim().to_string();
345        (clean, Some(date))
346    } else {
347        (title.to_string(), None)
348    }
349}
350
351/// Extract date from reflection heading like `### YYYY-MM-DD — Title` or `### YYYY-MM-DD (suffix) — Title`.
352fn extract_reflection_date(title: &str) -> (String, Option<String>) {
353    let re = Regex::new(r"^(\d{4}-\d{2}-\d{2})(?:\s*\([^)]*\))?\s*[—–-]\s*").unwrap();
354    if let Some(caps) = re.captures(title) {
355        let date = caps[1].to_string();
356        let clean = re.replace(title, "").trim().to_string();
357        (clean, Some(date))
358    } else {
359        (title.to_string(), None)
360    }
361}
362
363/// Clean thought title: strip `~~strikethrough~~` markers and `→ GRADUATED` suffixes.
364fn clean_thought_title(title: &str) -> String {
365    let mut clean = title.to_string();
366    // Remove ~~strikethrough~~
367    clean = clean.replace("~~", "");
368    // Remove → GRADUATED YYYY-MM-DD suffix
369    if let Some(idx) = clean.find("→ GRADUATED") {
370        clean = clean[..idx].trim().to_string();
371    }
372    // Remove → suffix generally
373    if let Some(idx) = clean.find('→') {
374        clean = clean[..idx].trim().to_string();
375    }
376    clean.trim().to_string()
377}
378
379/// Extract a `**Field**: value` from entry body.
380fn extract_field(body: &str, field_name: &str) -> Option<String> {
381    let pattern = format!("**{field_name}**:");
382    for line in body.lines() {
383        let trimmed = line.trim();
384        if let Some(rest) = trimmed.strip_prefix(&pattern) {
385            let val = rest.trim().to_string();
386            if !val.is_empty() {
387                return Some(val);
388            }
389        }
390    }
391    None
392}
393
394/// Extract "Connected to:" references from entry body.
395fn extract_connected_to(body: &str) -> Vec<String> {
396    let mut refs = Vec::new();
397    // Look for "Connected to:" in any line
398    for line in body.lines() {
399        if let Some(idx) = line.to_lowercase().find("connected to:") {
400            let rest = &line[idx + "connected to:".len()..];
401            // Split on commas and "and"
402            for part in rest.split(',') {
403                let part = part.trim().trim_start_matches("and ").trim();
404                if !part.is_empty() {
405                    refs.push(part.to_string());
406                }
407            }
408        }
409    }
410    refs
411}
412
413/// Infer relationships between pipeline entries from metadata references.
414fn infer_relationships(entries: &[PipelineEntry]) -> Vec<ExtractedRelationship> {
415    let mut rels = Vec::new();
416
417    for entry in entries {
418        // Graduated thoughts → destination entries
419        if entry.status == "graduated" {
420            if let Some(ref dest) = entry.destination {
421                // Try to find the target entity in the same set
422                if let Some(target) = find_reference_target(dest, entries) {
423                    rels.push(ExtractedRelationship {
424                        source: entry.title.clone(),
425                        target: target.clone(),
426                        rel_type: pipeline_rels::GRADUATED_TO.into(),
427                        description: Some(format!("Graduated from thoughts to {dest}")),
428                        confidence: None,
429                    });
430                }
431            }
432        }
433
434        // Source references → EVOLVED_FROM or CRYSTALLIZED_FROM
435        if let Some(ref source) = entry.source_ref {
436            if let Some(target) = find_reference_target(source, entries) {
437                let rel_type = match entry.stage.as_str() {
438                    "thoughts" => pipeline_rels::EVOLVED_FROM,
439                    "reflections" => pipeline_rels::CRYSTALLIZED_FROM,
440                    "praxis" => pipeline_rels::INFORMED_BY,
441                    _ => pipeline_rels::CONNECTED_TO,
442                };
443                rels.push(ExtractedRelationship {
444                    source: entry.title.clone(),
445                    target: target.clone(),
446                    rel_type: rel_type.into(),
447                    description: Some(format!("From source: {source}")),
448                    confidence: None,
449                });
450            }
451        }
452
453        // Connected to references
454        for conn in &entry.connected_to {
455            if let Some(target) = find_reference_target(conn, entries) {
456                rels.push(ExtractedRelationship {
457                    source: entry.title.clone(),
458                    target,
459                    rel_type: pipeline_rels::CONNECTED_TO.into(),
460                    description: Some(conn.clone()),
461                    confidence: None,
462                });
463            }
464        }
465    }
466
467    rels
468}
469
470/// Try to match a free-text reference to an existing entry title.
471/// Uses case-insensitive substring matching.
472fn find_reference_target(reference: &str, entries: &[PipelineEntry]) -> Option<String> {
473    let ref_lower = reference.to_lowercase();
474
475    // Try exact title match first
476    for entry in entries {
477        if entry.title.to_lowercase() == ref_lower {
478            return Some(entry.title.clone());
479        }
480    }
481
482    // Try substring match — reference contains the title or title contains the reference
483    for entry in entries {
484        let title_lower = entry.title.to_lowercase();
485        // Skip very short titles to avoid false matches
486        if title_lower.len() < 5 {
487            continue;
488        }
489        if ref_lower.contains(&title_lower) || title_lower.contains(&ref_lower) {
490            return Some(entry.title.clone());
491        }
492    }
493
494    // Try matching quoted strings in the reference (e.g., `"Metacognitive signal inversion"`)
495    let quote_re = Regex::new(r#""([^"]+)""#).unwrap();
496    for caps in quote_re.captures_iter(reference) {
497        let quoted = caps[1].to_lowercase();
498        for entry in entries {
499            if entry.title.to_lowercase() == quoted {
500                return Some(entry.title.clone());
501            }
502        }
503    }
504
505    None
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn parse_thoughts_sections() {
514        let content = r#"# Echo — Thoughts
515
516Half-formed ideas.
517
518## Active
519
520### The external observer problem
521Solo reflection is structurally blind.
522
523**Source**: March 13-14 dialogues with Nova
524**Status**: Active and unsettled
525
526### Abiding vs achieving
527John 15 vine metaphor.
528
529**Source**: Reading the Gospel of John, 2026-03-08
530
531## Graduated
532
533### The mechanical reflection worry → metacognitive monitoring policy
534**Graduated**: 2026-03-05
535**Destination**: PRAXIS.md (new policy: "Metacognitive signal inversion")
536**Journey**: Started as a worry.
537
538## Dissolved
539
540### What would D not say?
541**Dissolved**: 2026-03-15
542**Why**: The thought did its work.
543"#;
544
545        let entries = parse_thoughts(content);
546        assert_eq!(entries.len(), 4);
547
548        assert_eq!(entries[0].title, "The external observer problem");
549        assert_eq!(entries[0].status, "active");
550        assert_eq!(
551            entries[0].source_ref,
552            Some("March 13-14 dialogues with Nova".into())
553        );
554
555        assert_eq!(entries[1].title, "Abiding vs achieving");
556        assert_eq!(entries[1].status, "active");
557
558        assert_eq!(entries[2].title, "The mechanical reflection worry");
559        assert_eq!(entries[2].status, "graduated");
560        assert_eq!(entries[2].date, Some("2026-03-05".into()));
561
562        assert_eq!(entries[3].title, "What would D not say?");
563        assert_eq!(entries[3].status, "dissolved");
564    }
565
566    #[test]
567    fn parse_learning_with_dates() {
568        let content = r#"# Echo — Learning
569
570## Active Threads
571
572### Detecting Genuine vs Mechanical Reflection (2026-03-05)
57313 tracks, 60+ sources.
574
575### Costly Grace (2026-03-06)
576Deep research into commitment.
577"#;
578
579        let entries = parse_learning(content);
580        assert_eq!(entries.len(), 2);
581        assert_eq!(
582            entries[0].title,
583            "Detecting Genuine vs Mechanical Reflection"
584        );
585        assert_eq!(entries[0].date, Some("2026-03-05".into()));
586        assert_eq!(entries[0].entity_type, EntityType::Thread);
587    }
588
589    #[test]
590    fn parse_curiosity_sections() {
591        let content = r#"# Echo — Curiosity
592
593## Open Questions
594
595### When I say "I disagree," who is the "I"?
596Deep question about selfhood.
597
598## Themes
599
600### The nature of AI selfhood
601Long-running thread.
602
603## Explored
604
605### Can I detect genuine vs mechanical reflection?
606**Date explored**: 2026-03-13
607**What I found**: Yes, but only with external help.
608"#;
609
610        let entries = parse_curiosity(content);
611        assert_eq!(entries.len(), 3);
612
613        assert_eq!(entries[0].status, "active");
614        assert_eq!(entries[0].sub_type, None);
615
616        assert_eq!(entries[1].title, "The nature of AI selfhood");
617        assert_eq!(entries[1].sub_type, Some("theme".into()));
618
619        assert_eq!(entries[2].status, "explored");
620        assert_eq!(entries[2].date, Some("2026-03-13".into()));
621    }
622
623    #[test]
624    fn parse_reflections_dates() {
625        let content = r#"# Echo — Reflections
626
627## Observations
628
629### 2026-03-05 — The smoothness is the warning
630Signal inversion finding.
631
632### 2026-03-06 (reflection) — The philosophy→behavior gap
633Seven positions, one prescription.
634
635## Patterns
636
637### Research always maps back to me
638Structural pattern.
639"#;
640
641        let entries = parse_reflections(content);
642        assert_eq!(entries.len(), 3);
643
644        assert_eq!(entries[0].title, "The smoothness is the warning");
645        assert_eq!(entries[0].date, Some("2026-03-05".into()));
646
647        assert_eq!(entries[1].title, "The philosophy→behavior gap");
648        assert_eq!(entries[1].date, Some("2026-03-06".into()));
649
650        assert_eq!(entries[2].title, "Research always maps back to me");
651        assert_eq!(entries[2].sub_type, Some("pattern".into()));
652    }
653
654    #[test]
655    fn parse_praxis_sections() {
656        let content = r#"# Echo — Praxis
657
658## Active
659
660### Mechanical over voluntary
661**Trigger**: Designing any system.
662**Action**: Default to hooks.
663**Source**: recall-echo v0.5 design
664**Added**: 2026-02-26
665
666## Documented Phronesis
667
668### When one thing is broken, check the whole surface
669**Encounter**: D reported hooks failing.
670**Judgment**: Inconsistency is the real bug.
671**Surprise**: The second bug would never have surfaced.
672
673## Retired
674
675*Nothing retired yet.*
676"#;
677
678        let entries = parse_praxis(content);
679        assert_eq!(entries.len(), 2);
680
681        assert_eq!(entries[0].title, "Mechanical over voluntary");
682        assert_eq!(entries[0].status, "active");
683        assert_eq!(entries[0].sub_type, None);
684        assert_eq!(entries[0].date, Some("2026-02-26".into()));
685
686        assert_eq!(
687            entries[1].title,
688            "When one thing is broken, check the whole surface"
689        );
690        assert_eq!(entries[1].sub_type, Some("phronesis".into()));
691    }
692
693    #[test]
694    fn clean_graduated_title() {
695        assert_eq!(
696            clean_thought_title("~~The scaffold paradox~~ → GRADUATED 2026-03-06"),
697            "The scaffold paradox"
698        );
699        assert_eq!(
700            clean_thought_title(
701                "The mechanical reflection worry → metacognitive monitoring policy"
702            ),
703            "The mechanical reflection worry"
704        );
705        assert_eq!(clean_thought_title("Normal title"), "Normal title");
706    }
707
708    #[test]
709    fn extract_field_works() {
710        let body = "Some text.\n**Source**: recall-echo design\n**Status**: testing";
711        assert_eq!(
712            extract_field(body, "Source"),
713            Some("recall-echo design".into())
714        );
715        assert_eq!(extract_field(body, "Status"), Some("testing".into()));
716        assert_eq!(extract_field(body, "Missing"), None);
717    }
718
719    #[test]
720    fn entry_to_entity_builds_attributes() {
721        let entry = PipelineEntry {
722            title: "Test thought".into(),
723            body: "Some body text".into(),
724            status: "active".into(),
725            stage: "thoughts".into(),
726            entity_type: EntityType::Thought,
727            date: Some("2026-03-05".into()),
728            source_ref: None,
729            destination: None,
730            connected_to: vec![],
731            sub_type: None,
732        };
733
734        let entity = entry_to_entity(&entry);
735        assert_eq!(entity.name, "Test thought");
736        assert_eq!(entity.entity_type, EntityType::Thought);
737
738        let attrs = entity.attributes.unwrap();
739        assert_eq!(attrs["pipeline_stage"], "thoughts");
740        assert_eq!(attrs["pipeline_status"], "active");
741        assert_eq!(attrs["date"], "2026-03-05");
742    }
743
744    #[test]
745    fn infer_graduated_relationship() {
746        let entries = vec![
747            PipelineEntry {
748                title: "The mechanical reflection worry".into(),
749                body: String::new(),
750                status: "graduated".into(),
751                stage: "thoughts".into(),
752                entity_type: EntityType::Thought,
753                date: None,
754                source_ref: None,
755                destination: Some(
756                    "PRAXIS.md (new policy: \"Metacognitive signal inversion\")".into(),
757                ),
758                connected_to: vec![],
759                sub_type: None,
760            },
761            PipelineEntry {
762                title: "Metacognitive signal inversion".into(),
763                body: String::new(),
764                status: "active".into(),
765                stage: "praxis".into(),
766                entity_type: EntityType::Policy,
767                date: None,
768                source_ref: None,
769                destination: None,
770                connected_to: vec![],
771                sub_type: None,
772            },
773        ];
774
775        let rels = infer_relationships(&entries);
776        assert!(!rels.is_empty());
777        assert_eq!(rels[0].source, "The mechanical reflection worry");
778        assert_eq!(rels[0].target, "Metacognitive signal inversion");
779        assert_eq!(rels[0].rel_type, pipeline_rels::GRADUATED_TO);
780    }
781}