Skip to main content

recall_echo/graph/
pipeline.rs

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