Skip to main content

hippmem_write/
edges.rs

1//! Edge construction logic: strong/weak edges, observation zone, evidence,
2//! bidirectional registration, dedup (03 §3).
3
4use crate::candidates::CandidateResult;
5use crate::scoring::associate_score;
6use hippmem_core::config::AlgoParams;
7use hippmem_core::ids::MemoryId;
8use hippmem_core::model::links::{
9    AssociationLink, LinkDirection, LinkEvidence, LinkType, MatchDimension, ObservationState,
10};
11use hippmem_core::time::Timestamp;
12
13/// Edge construction parameters.
14pub struct EdgeBuildParams {
15    pub strong_threshold: f32,
16    pub strong_max: usize,
17    pub weak_max: usize,
18    pub min_score: f32,
19    pub observation_max: f32,
20    /// Max number of candidates compared per edge build (0 = no limit).
21    /// Default 0 (backward compatible).
22    /// Used to control O(n^2) edge-construction cost: only build edges with the
23    /// N most similar existing memories.
24    pub max_candidates: usize,
25}
26
27impl Default for EdgeBuildParams {
28    fn default() -> Self {
29        Self {
30            strong_threshold: 0.55,
31            strong_max: 8,
32            weak_max: 24,
33            min_score: 0.25,
34            observation_max: 0.55,
35            max_candidates: 0, // 0 = no limit, backward compatible
36        }
37    }
38}
39
40pub struct EdgeBuildResult {
41    pub created_links: Vec<AssociationLink>,
42}
43
44/// Build edges: dedup, no self-loop, bilateral registration.
45///
46/// - `edge_params`: edge thresholds (strong/weak/observation zone boundaries).
47/// - `algo_params`: configurable algorithm parameters (dimension weights /
48///   bonuses / penalties, passed to the scoring function).
49#[allow(clippy::too_many_arguments)]
50pub fn build_edges(
51    source_id: MemoryId,
52    target_id: MemoryId,
53    candidate: &CandidateResult,
54    dim_count: usize,
55    edge_params: &EdgeBuildParams,
56    algo_params: &AlgoParams,
57    existing_links: &[AssociationLink],
58    now: Timestamp,
59    total_memory_count: u32,
60) -> EdgeBuildResult {
61    if source_id == target_id {
62        return EdgeBuildResult {
63            created_links: vec![],
64        };
65    }
66
67    let score = associate_score(candidate, dim_count, total_memory_count, algo_params);
68    let sv = score.value();
69
70    if sv < edge_params.min_score {
71        return EdgeBuildResult {
72            created_links: vec![],
73        };
74    }
75
76    let link_type = determine_link_type(candidate);
77
78    // Dedup: (target, link_type)
79    if existing_links
80        .iter()
81        .any(|l| l.target_id == target_id && l.link_type == link_type)
82    {
83        return EdgeBuildResult {
84            created_links: vec![],
85        };
86    }
87
88    let observation = if sv <= edge_params.observation_max {
89        ObservationState::Observing { since: now }
90    } else {
91        ObservationState::Confirmed
92    };
93
94    let evidence = LinkEvidence {
95        contributing_dimensions: candidate.matched_dimensions.clone(),
96        score_breakdown: candidate
97            .matched_dimensions
98            .iter()
99            .map(|&d| (d, sv))
100            .collect(),
101        text_spans: vec![],
102        note: None,
103    };
104
105    let link = AssociationLink {
106        target_id,
107        link_type,
108        direction: LinkDirection::Forward,
109        strength: score,
110        confidence: score,
111        evidence,
112        formed_at: now,
113        last_activated_at: Some(now),
114        activation_count: 0,
115        observation,
116    };
117
118    EdgeBuildResult {
119        created_links: vec![link],
120    }
121}
122
123fn determine_link_type(candidate: &CandidateResult) -> LinkType {
124    let dims = &candidate.matched_dimensions;
125    // Detect the more specific association type first
126    if dims.contains(&MatchDimension::Causal) {
127        LinkType::Causal
128    } else if dims.contains(&MatchDimension::Entity) {
129        LinkType::EntityOverlap
130    } else if dims.contains(&MatchDimension::Temporal) {
131        LinkType::TemporalAdjacent
132    } else if dims.contains(&MatchDimension::Topic) {
133        LinkType::TopicRelated
134    } else if dims.contains(&MatchDimension::Goal) {
135        LinkType::SameGoal
136    } else if dims.contains(&MatchDimension::Event) {
137        LinkType::SameEvent
138    } else {
139        LinkType::SemanticSimilar
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use hippmem_core::score::UnitScore;
147
148    fn make_cand(dims: Vec<MatchDimension>, n: usize) -> CandidateResult {
149        CandidateResult {
150            matched_dimensions: dims,
151            entity_jaccard: if n >= 1 { 0.6 } else { 0.0 },
152            topic_jaccard: if n >= 2 { 0.5 } else { 0.0 },
153            temporal_overlap: if n >= 3 { 1 } else { 0 },
154            goal_jaccard: 0.0,
155            event_jaccard: 0.0,
156            causal_overlap: 0,
157            emotion_overlap: 0,
158            importance_value: 0.0,
159            co_context_score: 0.0,
160            lexical_similarity: 0.8,
161            semantic_binary_similarity: 0.0,
162        }
163    }
164
165    #[test]
166    fn no_self_loop() {
167        let c = make_cand(vec![MatchDimension::Entity], 1);
168        let r = build_edges(
169            MemoryId(1),
170            MemoryId(1),
171            &c,
172            2,
173            &EdgeBuildParams::default(),
174            &AlgoParams::default(),
175            &[],
176            Timestamp(0),
177            1000,
178        );
179        assert!(r.created_links.is_empty());
180    }
181
182    #[test]
183    fn dedup_skips_existing() {
184        let c = make_cand(vec![MatchDimension::Entity], 2);
185        let existing = vec![AssociationLink {
186            target_id: MemoryId(2),
187            link_type: LinkType::EntityOverlap,
188            direction: LinkDirection::Forward,
189            strength: UnitScore::new(0.5),
190            confidence: UnitScore::new(0.5),
191            evidence: LinkEvidence {
192                contributing_dimensions: vec![],
193                score_breakdown: vec![],
194                text_spans: vec![],
195                note: None,
196            },
197            formed_at: Timestamp(0),
198            last_activated_at: None,
199            activation_count: 0,
200            observation: ObservationState::Confirmed,
201        }];
202        let r = build_edges(
203            MemoryId(1),
204            MemoryId(2),
205            &c,
206            2,
207            &EdgeBuildParams::default(),
208            &AlgoParams::default(),
209            &existing,
210            Timestamp(0),
211            1000,
212        );
213        assert!(r.created_links.is_empty());
214    }
215
216    #[test]
217    fn strong_edge_with_multi_dim() {
218        let c = make_cand(
219            vec![
220                MatchDimension::Entity,
221                MatchDimension::Topic,
222                MatchDimension::Temporal,
223            ],
224            3,
225        );
226        let r = build_edges(
227            MemoryId(1),
228            MemoryId(2),
229            &c,
230            3,
231            &EdgeBuildParams::default(),
232            &AlgoParams::default(),
233            &[],
234            Timestamp(0),
235            1000,
236        );
237        assert!(!r.created_links.is_empty());
238    }
239}