rag_plusplus_core/trajectory/
chainlink.rs

1//! ChainLink Interaction Framework
2//!
3//! Implements the ChainLink estimator from the DLM package for computing
4//! relationship strength between elements in a response chain.
5//!
6//! # Overview
7//!
8//! ChainLink represents a link in a response chain with associated metadata:
9//! - Coordinates (position in trajectory)
10//! - Embeddings (semantic representation)
11//! - Link type (continuation, elaboration, contradiction, etc.)
12//! - Attention weights (forward, inverse, cross)
13//!
14//! # 4-Component Estimation
15//!
16//! The ChainLink estimator uses 4 components:
17//!
18//! | Component | Weight | Description |
19//! |-----------|--------|-------------|
20//! | Baseline | 0.20 | Base semantic similarity |
21//! | Relationship | 0.30 | Relationship strength (coordinate + attention) |
22//! | Type-based | 0.20 | Weight based on link type compatibility |
23//! | Context-weighted | 0.30 | Context-aware weighted similarity |
24//!
25//! # Usage
26//!
27//! ```ignore
28//! use rag_plusplus_core::trajectory::chainlink::{ChainLink, ChainLinkEstimator};
29//!
30//! let link_a = ChainLink::new(coord_a, embedding_a, LinkType::Continuation);
31//! let link_b = ChainLink::new(coord_b, embedding_b, LinkType::Elaboration);
32//!
33//! let estimator = ChainLinkEstimator::default();
34//! let strength = estimator.estimate(&link_a, &link_b);
35//! ```
36
37use crate::distance::cosine_similarity_fast;
38use crate::trajectory::{TrajectoryCoordinate5D, DLMWeights, AttentionWeights};
39
40/// Types of links in a response chain.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum LinkType {
43    /// Direct continuation of previous content
44    Continuation,
45    /// Elaboration or expansion of previous content
46    Elaboration,
47    /// Summarization or compression
48    Summary,
49    /// Contradiction or correction
50    Contradiction,
51    /// New topic or tangent
52    Tangent,
53    /// Question about previous content
54    Question,
55    /// Answer to a previous question
56    Answer,
57    /// Code implementation
58    Code,
59    /// Error or problem statement
60    Error,
61    /// Solution or fix
62    Solution,
63    /// Meta-commentary or reflection
64    Meta,
65    /// Unknown or unclassified
66    Unknown,
67}
68
69impl LinkType {
70    /// Get numeric value for compatibility matrix lookup.
71    pub fn as_index(&self) -> usize {
72        match self {
73            Self::Continuation => 0,
74            Self::Elaboration => 1,
75            Self::Summary => 2,
76            Self::Contradiction => 3,
77            Self::Tangent => 4,
78            Self::Question => 5,
79            Self::Answer => 6,
80            Self::Code => 7,
81            Self::Error => 8,
82            Self::Solution => 9,
83            Self::Meta => 10,
84            Self::Unknown => 11,
85        }
86    }
87
88    /// Parse from string.
89    pub fn from_str(s: &str) -> Self {
90        match s.to_lowercase().as_str() {
91            "continuation" | "continue" => Self::Continuation,
92            "elaboration" | "elaborate" | "expand" => Self::Elaboration,
93            "summary" | "summarize" | "compress" => Self::Summary,
94            "contradiction" | "contradict" | "correct" => Self::Contradiction,
95            "tangent" | "aside" | "digression" => Self::Tangent,
96            "question" | "ask" | "query" => Self::Question,
97            "answer" | "reply" | "respond" => Self::Answer,
98            "code" | "implementation" | "impl" => Self::Code,
99            "error" | "problem" | "issue" | "bug" => Self::Error,
100            "solution" | "fix" | "resolve" => Self::Solution,
101            "meta" | "reflection" | "comment" => Self::Meta,
102            _ => Self::Unknown,
103        }
104    }
105
106    /// Get human-readable name.
107    pub fn name(&self) -> &'static str {
108        match self {
109            Self::Continuation => "continuation",
110            Self::Elaboration => "elaboration",
111            Self::Summary => "summary",
112            Self::Contradiction => "contradiction",
113            Self::Tangent => "tangent",
114            Self::Question => "question",
115            Self::Answer => "answer",
116            Self::Code => "code",
117            Self::Error => "error",
118            Self::Solution => "solution",
119            Self::Meta => "meta",
120            Self::Unknown => "unknown",
121        }
122    }
123
124    /// Check if this is a "constructive" link type.
125    pub fn is_constructive(&self) -> bool {
126        matches!(
127            self,
128            Self::Continuation | Self::Elaboration | Self::Answer | Self::Solution
129        )
130    }
131
132    /// Check if this is a "questioning" link type.
133    pub fn is_questioning(&self) -> bool {
134        matches!(self, Self::Question | Self::Error | Self::Contradiction)
135    }
136}
137
138impl Default for LinkType {
139    fn default() -> Self {
140        Self::Unknown
141    }
142}
143
144/// A link in a response chain with coordinates, embedding, and metadata.
145#[derive(Debug, Clone)]
146pub struct ChainLink {
147    /// Position in trajectory
148    pub coordinate: TrajectoryCoordinate5D,
149
150    /// Semantic embedding
151    pub embedding: Vec<f32>,
152
153    /// Type of link
154    pub link_type: LinkType,
155
156    /// Influence weight (0-1)
157    pub influence: f32,
158
159    /// Whether this is a user message
160    pub is_user: bool,
161
162    /// Attention weights (if computed)
163    pub attention: Option<AttentionWeights>,
164
165    /// Optional identifier
166    pub id: Option<String>,
167}
168
169impl ChainLink {
170    /// Create a new chain link.
171    pub fn new(
172        coordinate: TrajectoryCoordinate5D,
173        embedding: Vec<f32>,
174        link_type: LinkType,
175    ) -> Self {
176        Self {
177            coordinate,
178            embedding,
179            link_type,
180            influence: 0.5,
181            is_user: false,
182            attention: None,
183            id: None,
184        }
185    }
186
187    /// Create with full metadata.
188    pub fn with_metadata(
189        coordinate: TrajectoryCoordinate5D,
190        embedding: Vec<f32>,
191        link_type: LinkType,
192        influence: f32,
193        is_user: bool,
194    ) -> Self {
195        Self {
196            coordinate,
197            embedding,
198            link_type,
199            influence: influence.clamp(0.0, 1.0),
200            is_user,
201            attention: None,
202            id: None,
203        }
204    }
205
206    /// Set attention weights.
207    pub fn with_attention(mut self, attention: AttentionWeights) -> Self {
208        self.attention = Some(attention);
209        self
210    }
211
212    /// Set identifier.
213    pub fn with_id(mut self, id: impl Into<String>) -> Self {
214        self.id = Some(id.into());
215        self
216    }
217
218    /// Compute semantic similarity to another link.
219    pub fn semantic_similarity(&self, other: &Self) -> f32 {
220        cosine_similarity_fast(&self.embedding, &other.embedding)
221    }
222
223    /// Compute coordinate distance to another link.
224    pub fn coordinate_distance(&self, other: &Self, weights: &DLMWeights) -> f32 {
225        self.coordinate.dlm_distance(&other.coordinate, weights)
226    }
227}
228
229/// Configuration for the ChainLink estimator.
230#[derive(Debug, Clone)]
231pub struct ChainLinkEstimatorConfig {
232    /// Weight for baseline similarity component
233    pub baseline_weight: f32,
234
235    /// Weight for relationship strength component
236    pub relationship_weight: f32,
237
238    /// Weight for type-based component
239    pub type_weight: f32,
240
241    /// Weight for context-weighted component
242    pub context_weight: f32,
243
244    /// DLM weights for coordinate distance
245    pub coord_weights: DLMWeights,
246
247    /// Type compatibility matrix (12x12)
248    /// Values represent compatibility between link types [0, 1]
249    pub type_compatibility: [[f32; 12]; 12],
250}
251
252impl Default for ChainLinkEstimatorConfig {
253    fn default() -> Self {
254        Self {
255            baseline_weight: 0.20,
256            relationship_weight: 0.30,
257            type_weight: 0.20,
258            context_weight: 0.30,
259            coord_weights: DLMWeights::default(),
260            type_compatibility: Self::default_compatibility_matrix(),
261        }
262    }
263}
264
265impl ChainLinkEstimatorConfig {
266    /// Create the default type compatibility matrix.
267    ///
268    /// Higher values mean the types work well together.
269    fn default_compatibility_matrix() -> [[f32; 12]; 12] {
270        // Types: Continuation, Elaboration, Summary, Contradiction, Tangent,
271        //        Question, Answer, Code, Error, Solution, Meta, Unknown
272        let mut matrix = [[0.5; 12]; 12];
273
274        // Self-compatibility (same type)
275        for i in 0..12 {
276            matrix[i][i] = 0.8;
277        }
278
279        // High compatibility pairs
280        let high_compat = [
281            (0, 1), // Continuation <-> Elaboration
282            (1, 0),
283            (0, 2), // Continuation <-> Summary
284            (2, 0),
285            (5, 6), // Question <-> Answer
286            (6, 5),
287            (7, 8), // Code <-> Error
288            (8, 7),
289            (8, 9), // Error <-> Solution
290            (9, 8),
291            (7, 9), // Code <-> Solution
292            (9, 7),
293        ];
294        for (i, j) in high_compat {
295            matrix[i][j] = 0.9;
296        }
297
298        // Medium compatibility
299        let medium_compat = [
300            (1, 2), // Elaboration <-> Summary
301            (2, 1),
302            (3, 9), // Contradiction <-> Solution
303            (9, 3),
304            (4, 5), // Tangent <-> Question
305            (5, 4),
306        ];
307        for (i, j) in medium_compat {
308            matrix[i][j] = 0.7;
309        }
310
311        // Low compatibility
312        let low_compat = [
313            (0, 4), // Continuation <-> Tangent
314            (4, 0),
315            (3, 0), // Contradiction <-> Continuation
316            (0, 3),
317        ];
318        for (i, j) in low_compat {
319            matrix[i][j] = 0.3;
320        }
321
322        matrix
323    }
324
325    /// Get type compatibility score.
326    pub fn get_type_compatibility(&self, type_a: LinkType, type_b: LinkType) -> f32 {
327        self.type_compatibility[type_a.as_index()][type_b.as_index()]
328    }
329
330    /// Create config with custom component weights.
331    pub fn with_weights(
332        baseline: f32,
333        relationship: f32,
334        type_weight: f32,
335        context: f32,
336    ) -> Self {
337        // Normalize weights
338        let total = baseline + relationship + type_weight + context;
339        Self {
340            baseline_weight: baseline / total,
341            relationship_weight: relationship / total,
342            type_weight: type_weight / total,
343            context_weight: context / total,
344            ..Default::default()
345        }
346    }
347}
348
349/// ChainLink estimator for computing relationship strength.
350#[derive(Debug, Clone)]
351pub struct ChainLinkEstimator {
352    config: ChainLinkEstimatorConfig,
353}
354
355impl ChainLinkEstimator {
356    /// Create a new estimator with configuration.
357    pub fn new(config: ChainLinkEstimatorConfig) -> Self {
358        Self { config }
359    }
360
361    /// Compute baseline similarity component.
362    ///
363    /// Pure semantic similarity between embeddings.
364    #[inline]
365    fn compute_baseline(&self, link_a: &ChainLink, link_b: &ChainLink) -> f32 {
366        // Normalize from [-1, 1] to [0, 1]
367        (1.0 + link_a.semantic_similarity(link_b)) / 2.0
368    }
369
370    /// Compute relationship strength component.
371    ///
372    /// Combines coordinate distance and influence weights.
373    #[inline]
374    fn compute_relationship(&self, link_a: &ChainLink, link_b: &ChainLink) -> f32 {
375        // Coordinate-based proximity
376        let coord_dist = link_a.coordinate_distance(link_b, &self.config.coord_weights);
377        let coord_sim = (-coord_dist).exp(); // [0, 1]
378
379        // Influence combination
380        let influence_avg = (link_a.influence + link_b.influence) / 2.0;
381
382        // Attention-based (if available)
383        let attention_score = match (&link_a.attention, &link_b.attention) {
384            (Some(att_a), Some(att_b)) => {
385                // Cross-attention if different roles
386                if link_a.is_user != link_b.is_user {
387                    (att_a.total_mass + att_b.total_mass) / 2.0
388                } else {
389                    // Forward attention otherwise
390                    let entropy_a = att_a.forward_entropy();
391                    let entropy_b = att_b.forward_entropy();
392                    // Lower entropy = more focused = higher score
393                    1.0 / (1.0 + (entropy_a + entropy_b) / 2.0)
394                }
395            }
396            _ => 0.5, // Neutral if no attention
397        };
398
399        // Weighted combination
400        0.4 * coord_sim + 0.3 * influence_avg + 0.3 * attention_score
401    }
402
403    /// Compute type-based weight component.
404    ///
405    /// Uses the compatibility matrix for link types.
406    #[inline]
407    fn compute_type_based(&self, link_a: &ChainLink, link_b: &ChainLink) -> f32 {
408        self.config
409            .get_type_compatibility(link_a.link_type, link_b.link_type)
410    }
411
412    /// Compute context-weighted component.
413    ///
414    /// Combines semantic similarity with positional context.
415    #[inline]
416    fn compute_context_weighted(&self, link_a: &ChainLink, link_b: &ChainLink) -> f32 {
417        let semantic = (1.0 + link_a.semantic_similarity(link_b)) / 2.0;
418
419        // Temporal proximity bonus
420        let temporal_diff = (link_a.coordinate.temporal - link_b.coordinate.temporal).abs();
421        let temporal_bonus = 1.0 - temporal_diff; // Higher when close in time
422
423        // Homogeneity alignment
424        let homo_diff = (link_a.coordinate.homogeneity - link_b.coordinate.homogeneity).abs();
425        let homo_bonus = 1.0 - homo_diff;
426
427        // Role alignment (same role = slightly higher)
428        let role_bonus = if link_a.is_user == link_b.is_user {
429            0.1
430        } else {
431            0.0
432        };
433
434        // Weighted combination
435        0.5 * semantic + 0.2 * temporal_bonus + 0.2 * homo_bonus + 0.1 * (0.5 + role_bonus)
436    }
437
438    /// Estimate relationship strength between two links.
439    ///
440    /// Returns a value in [0, 1] where higher = stronger relationship.
441    pub fn estimate(&self, link_a: &ChainLink, link_b: &ChainLink) -> f32 {
442        let baseline = self.compute_baseline(link_a, link_b);
443        let relationship = self.compute_relationship(link_a, link_b);
444        let type_based = self.compute_type_based(link_a, link_b);
445        let context = self.compute_context_weighted(link_a, link_b);
446
447        self.config.baseline_weight * baseline
448            + self.config.relationship_weight * relationship
449            + self.config.type_weight * type_based
450            + self.config.context_weight * context
451    }
452
453    /// Estimate relationship with detailed breakdown.
454    pub fn estimate_detailed(&self, link_a: &ChainLink, link_b: &ChainLink) -> ChainLinkEstimate {
455        let baseline = self.compute_baseline(link_a, link_b);
456        let relationship = self.compute_relationship(link_a, link_b);
457        let type_based = self.compute_type_based(link_a, link_b);
458        let context = self.compute_context_weighted(link_a, link_b);
459
460        let total = self.config.baseline_weight * baseline
461            + self.config.relationship_weight * relationship
462            + self.config.type_weight * type_based
463            + self.config.context_weight * context;
464
465        ChainLinkEstimate {
466            total,
467            baseline,
468            relationship,
469            type_based,
470            context,
471        }
472    }
473
474    /// Get configuration.
475    pub fn config(&self) -> &ChainLinkEstimatorConfig {
476        &self.config
477    }
478}
479
480impl Default for ChainLinkEstimator {
481    fn default() -> Self {
482        Self::new(ChainLinkEstimatorConfig::default())
483    }
484}
485
486/// Detailed estimate breakdown.
487#[derive(Debug, Clone, Copy)]
488pub struct ChainLinkEstimate {
489    /// Total estimated relationship strength [0, 1]
490    pub total: f32,
491    /// Baseline semantic similarity component
492    pub baseline: f32,
493    /// Relationship strength component
494    pub relationship: f32,
495    /// Type-based compatibility component
496    pub type_based: f32,
497    /// Context-weighted component
498    pub context: f32,
499}
500
501impl ChainLinkEstimate {
502    /// Check if the estimate indicates a strong relationship.
503    pub fn is_strong(&self) -> bool {
504        self.total > 0.7
505    }
506
507    /// Check if the estimate indicates a weak relationship.
508    pub fn is_weak(&self) -> bool {
509        self.total < 0.3
510    }
511
512    /// Get the dominant component.
513    pub fn dominant_component(&self) -> &'static str {
514        let components = [
515            (self.baseline, "baseline"),
516            (self.relationship, "relationship"),
517            (self.type_based, "type_based"),
518            (self.context, "context"),
519        ];
520
521        components
522            .iter()
523            .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
524            .map(|(_, name)| *name)
525            .unwrap_or("unknown")
526    }
527}
528
529/// Compute pairwise relationship matrix for a chain of links.
530pub fn compute_chain_matrix(
531    estimator: &ChainLinkEstimator,
532    links: &[ChainLink],
533) -> Vec<Vec<f32>> {
534    let n = links.len();
535    let mut matrix = vec![vec![0.0; n]; n];
536
537    for i in 0..n {
538        for j in 0..n {
539            if i == j {
540                matrix[i][j] = 1.0; // Self-relationship
541            } else {
542                matrix[i][j] = estimator.estimate(&links[i], &links[j]);
543            }
544        }
545    }
546
547    matrix
548}
549
550/// Find the strongest link for each position.
551pub fn find_strongest_links(
552    estimator: &ChainLinkEstimator,
553    links: &[ChainLink],
554) -> Vec<Option<(usize, f32)>> {
555    let n = links.len();
556    let mut strongest = vec![None; n];
557
558    for i in 0..n {
559        let mut best: Option<(usize, f32)> = None;
560
561        for j in 0..n {
562            if i == j {
563                continue;
564            }
565
566            let strength = estimator.estimate(&links[i], &links[j]);
567
568            match best {
569                Some((_, best_strength)) if strength > best_strength => {
570                    best = Some((j, strength));
571                }
572                None => {
573                    best = Some((j, strength));
574                }
575                _ => {}
576            }
577        }
578
579        strongest[i] = best;
580    }
581
582    strongest
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    fn make_test_link(
590        depth: u32,
591        temporal: f32,
592        embedding_seed: f32,
593        link_type: LinkType,
594    ) -> ChainLink {
595        let coord = TrajectoryCoordinate5D::new(depth, 0, 0.8, temporal, 1);
596        let embedding: Vec<f32> = (0..8)
597            .map(|i| (embedding_seed + i as f32 * 0.1).sin())
598            .collect();
599
600        ChainLink::new(coord, embedding, link_type)
601    }
602
603    #[test]
604    fn test_link_type_parsing() {
605        assert_eq!(LinkType::from_str("continuation"), LinkType::Continuation);
606        assert_eq!(LinkType::from_str("QUESTION"), LinkType::Question);
607        assert_eq!(LinkType::from_str("code"), LinkType::Code);
608        assert_eq!(LinkType::from_str("unknown_type"), LinkType::Unknown);
609    }
610
611    #[test]
612    fn test_link_type_properties() {
613        assert!(LinkType::Continuation.is_constructive());
614        assert!(LinkType::Answer.is_constructive());
615        assert!(!LinkType::Question.is_constructive());
616
617        assert!(LinkType::Question.is_questioning());
618        assert!(LinkType::Error.is_questioning());
619        assert!(!LinkType::Answer.is_questioning());
620    }
621
622    #[test]
623    fn test_chain_link_creation() {
624        let coord = TrajectoryCoordinate5D::new(2, 0, 0.9, 0.5, 1);
625        let embedding = vec![0.5; 8];
626        let link = ChainLink::new(coord, embedding, LinkType::Continuation);
627
628        assert!((link.influence - 0.5).abs() < 1e-6);
629        assert!(!link.is_user);
630        assert!(link.attention.is_none());
631    }
632
633    #[test]
634    fn test_chain_link_similarity() {
635        let link_a = make_test_link(1, 0.2, 1.0, LinkType::Continuation);
636        let link_b = make_test_link(2, 0.4, 1.1, LinkType::Elaboration);
637
638        let sim = link_a.semantic_similarity(&link_b);
639        assert!(sim > 0.9); // Similar embeddings
640
641        let link_c = make_test_link(3, 0.6, 5.0, LinkType::Tangent);
642        let sim_c = link_a.semantic_similarity(&link_c);
643        assert!(sim_c < sim); // Less similar
644    }
645
646    #[test]
647    fn test_estimator_config_default() {
648        let config = ChainLinkEstimatorConfig::default();
649
650        // Weights should sum to 1.0
651        let total = config.baseline_weight
652            + config.relationship_weight
653            + config.type_weight
654            + config.context_weight;
655        assert!((total - 1.0).abs() < 1e-6);
656    }
657
658    #[test]
659    fn test_estimator_type_compatibility() {
660        let config = ChainLinkEstimatorConfig::default();
661
662        // Question-Answer should be highly compatible
663        let qa_compat = config.get_type_compatibility(LinkType::Question, LinkType::Answer);
664        assert!(qa_compat > 0.8);
665
666        // Error-Solution should be highly compatible
667        let es_compat = config.get_type_compatibility(LinkType::Error, LinkType::Solution);
668        assert!(es_compat > 0.8);
669
670        // Continuation-Tangent should be low compatibility
671        let ct_compat = config.get_type_compatibility(LinkType::Continuation, LinkType::Tangent);
672        assert!(ct_compat < 0.5);
673    }
674
675    #[test]
676    fn test_estimator_estimate() {
677        let estimator = ChainLinkEstimator::default();
678
679        let link_a = make_test_link(1, 0.2, 1.0, LinkType::Question);
680        let link_b = make_test_link(2, 0.4, 1.1, LinkType::Answer);
681
682        let strength = estimator.estimate(&link_a, &link_b);
683
684        // Should be positive and reasonable
685        assert!(strength > 0.0);
686        assert!(strength <= 1.0);
687    }
688
689    #[test]
690    fn test_estimator_self_similarity() {
691        let estimator = ChainLinkEstimator::default();
692
693        let link = make_test_link(2, 0.5, 1.0, LinkType::Continuation);
694        let strength = estimator.estimate(&link, &link);
695
696        // Self-similarity should be very high
697        assert!(strength > 0.8);
698    }
699
700    #[test]
701    fn test_estimator_detailed() {
702        let estimator = ChainLinkEstimator::default();
703
704        let link_a = make_test_link(1, 0.2, 1.0, LinkType::Error);
705        let link_b = make_test_link(2, 0.4, 1.2, LinkType::Solution);
706
707        let estimate = estimator.estimate_detailed(&link_a, &link_b);
708
709        assert!(estimate.total > 0.0);
710        assert!(estimate.baseline > 0.0);
711        assert!(estimate.relationship > 0.0);
712        assert!(estimate.type_based > 0.0);
713        assert!(estimate.context > 0.0);
714
715        // Type-based should be high for Error-Solution
716        assert!(estimate.type_based > 0.8);
717    }
718
719    #[test]
720    fn test_estimate_dominant_component() {
721        let estimate = ChainLinkEstimate {
722            total: 0.75,
723            baseline: 0.9,
724            relationship: 0.6,
725            type_based: 0.7,
726            context: 0.5,
727        };
728
729        assert_eq!(estimate.dominant_component(), "baseline");
730        assert!(estimate.is_strong());
731        assert!(!estimate.is_weak());
732    }
733
734    #[test]
735    fn test_compute_chain_matrix() {
736        let estimator = ChainLinkEstimator::default();
737
738        let links = vec![
739            make_test_link(1, 0.1, 1.0, LinkType::Question),
740            make_test_link(2, 0.3, 1.2, LinkType::Answer),
741            make_test_link(3, 0.5, 1.4, LinkType::Elaboration),
742        ];
743
744        let matrix = compute_chain_matrix(&estimator, &links);
745
746        assert_eq!(matrix.len(), 3);
747        assert_eq!(matrix[0].len(), 3);
748
749        // Diagonal should be 1.0 (self-relationship)
750        assert!((matrix[0][0] - 1.0).abs() < 1e-6);
751        assert!((matrix[1][1] - 1.0).abs() < 1e-6);
752        assert!((matrix[2][2] - 1.0).abs() < 1e-6);
753
754        // Matrix should be symmetric for same links
755        // (not guaranteed for all cases due to role differences)
756    }
757
758    #[test]
759    fn test_find_strongest_links() {
760        let estimator = ChainLinkEstimator::default();
761
762        let links = vec![
763            make_test_link(1, 0.1, 1.0, LinkType::Question),
764            make_test_link(2, 0.3, 1.1, LinkType::Answer), // Close to Q
765            make_test_link(3, 0.8, 5.0, LinkType::Tangent), // Far from others
766        ];
767
768        let strongest = find_strongest_links(&estimator, &links);
769
770        assert_eq!(strongest.len(), 3);
771
772        // Question's strongest should be Answer (index 1)
773        assert!(strongest[0].is_some());
774
775        // All should have a strongest link
776        for (i, s) in strongest.iter().enumerate() {
777            assert!(s.is_some(), "Link {} should have a strongest connection", i);
778        }
779    }
780
781    #[test]
782    fn test_chain_link_with_metadata() {
783        let coord = TrajectoryCoordinate5D::new(2, 0, 0.9, 0.5, 1);
784        let embedding = vec![0.5; 8];
785        let link = ChainLink::with_metadata(
786            coord,
787            embedding,
788            LinkType::Continuation,
789            0.8,
790            true, // is_user
791        );
792
793        assert!((link.influence - 0.8).abs() < 1e-6);
794        assert!(link.is_user);
795    }
796
797    #[test]
798    fn test_chain_link_fluent_api() {
799        let coord = TrajectoryCoordinate5D::new(2, 0, 0.9, 0.5, 1);
800        let embedding = vec![0.5; 8];
801        let attention = AttentionWeights::uniform(3);
802
803        let link = ChainLink::new(coord, embedding, LinkType::Continuation)
804            .with_attention(attention)
805            .with_id("test_link_1");
806
807        assert!(link.attention.is_some());
808        assert_eq!(link.id, Some("test_link_1".to_string()));
809    }
810}