Skip to main content

hippmem_write/
staged.rs

1//! Staged write path: raw -> indexed (03/05).
2
3use crate::candidates::{discover_candidates, simhash_similarity};
4use crate::edges::{build_edges, EdgeBuildParams};
5use crate::keys::generate_keys;
6use hippmem_core::config::AlgoParams;
7use hippmem_core::ids::MemoryId;
8use hippmem_core::model::links::{ActivationState, AssociationLink, SemanticSignature};
9use hippmem_core::model::understanding::MemoryUnderstanding;
10use hippmem_core::model::unit::{
11    MemoryContent, MemoryLifecycle, MemoryStage, MemoryUnit, WriteContext,
12};
13use hippmem_core::score::UnitScore;
14
15pub struct StagedWriteInput {
16    pub id: MemoryId,
17    pub content: MemoryContent,
18    pub understanding: MemoryUnderstanding,
19    pub context: WriteContext,
20    pub semantic: SemanticSignature,
21}
22
23pub struct StagedWriteOutput {
24    pub unit: MemoryUnit,
25    pub created_links: Vec<AssociationLink>,
26}
27
28/// raw -> indexed: generate keys and build initial edges.
29pub fn raw_to_indexed(
30    input: StagedWriteInput,
31    existing_units: &[MemoryUnit],
32    edge_params: &EdgeBuildParams,
33    algo_params: &AlgoParams,
34) -> Result<StagedWriteOutput, String> {
35    let now = input.context.local_time;
36    let keys = generate_keys(
37        &input.content,
38        &input.understanding,
39        &input.context,
40        &input.semantic,
41    )?;
42
43    // Candidate pre-filter: sort by SimHash similarity and cap the candidate
44    // count to control O(n^2) cost
45    let mut candidates: Vec<(&MemoryUnit, f32)> = existing_units
46        .iter()
47        .map(|unit| {
48            // Fast SimHash similarity (cheap, no full Jaccard needed)
49            let sim = simhash_similarity(
50                &keys.lexical_signature.simhash,
51                &unit.association_keys.lexical_signature.simhash,
52            );
53            (unit, sim)
54        })
55        .collect();
56
57    // Sort by similarity descending, take top max_candidates (0 = no limit)
58    candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
59    if edge_params.max_candidates > 0 {
60        candidates.truncate(edge_params.max_candidates);
61    }
62
63    let total_memories = (existing_units.len() + 1) as u32;
64    let mut all_links = Vec::new();
65    for (existing, _sim) in &candidates {
66        let mut cand = discover_candidates(&keys, &existing.association_keys);
67        // Fill in dimensions that discover_candidates cannot derive:
68        // importance: importance of the existing memory (03 §2.1)
69        cand.importance_value = existing.understanding.importance.value();
70        // co_context: shared context ratio (03 §2.1)
71        cand.co_context_score = context_shared_ratio(&input.context, &existing.context);
72        let result = build_edges(
73            input.id,
74            existing.id,
75            &cand,
76            cand.matched_dimensions.len(),
77            edge_params,
78            algo_params,
79            &existing.links,
80            now,
81            total_memories,
82        );
83        all_links.extend(result.created_links);
84    }
85
86    let unit = MemoryUnit {
87        schema_version: 1,
88        id: input.id,
89        created_at: now,
90        updated_at: now,
91        content: input.content,
92        context: input.context,
93        understanding: input.understanding,
94        association_keys: keys,
95        links: all_links.clone(),
96        activation: ActivationState {
97            last_retrieved_at: None,
98            retrieval_count: 0,
99            co_activations: vec![],
100            usage_score: UnitScore::new(0.5),
101        },
102        lifecycle: MemoryLifecycle::Active,
103        provenance: hippmem_core::model::unit::Provenance {
104            origin: hippmem_core::model::unit::SourceKind::Conversation,
105            generated_by: hippmem_core::model::unit::GeneratedBy::UserDirect,
106            reliability: UnitScore::new(0.5),
107            evidence_refs: vec![],
108            revision_history: vec![],
109        },
110        stage: MemoryStage::Indexed,
111    };
112
113    Ok(StagedWriteOutput {
114        unit,
115        created_links: all_links,
116    })
117}
118
119/// Compute the shared ratio of two WriteContexts: shared field count / total
120/// non-None field count.
121/// Result is in [0, 1], used for co_context_score (03 §2.1).
122fn context_shared_ratio(a: &WriteContext, b: &WriteContext) -> f32 {
123    let fields = [
124        (a.conversation_id, b.conversation_id),
125        (a.session_id, b.session_id),
126        (a.project_id, b.project_id),
127        (a.task_id, b.task_id),
128    ];
129    let total: usize = fields
130        .iter()
131        .filter(|(fa, fb)| fa.is_some() || fb.is_some())
132        .count();
133    if total == 0 {
134        return 0.0;
135    }
136    let shared = fields
137        .iter()
138        .filter(|(fa, fb)| fa.is_some() && fa == fb)
139        .count();
140    shared as f32 / total as f32
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use hippmem_core::model::understanding::{EntityMention, EntityType};
147    use hippmem_core::model::unit::{ContentType, Language};
148    use hippmem_core::time::Timestamp;
149
150    fn make_input(id: u128, text: &str, entity: &str) -> StagedWriteInput {
151        StagedWriteInput {
152            id: MemoryId(id),
153            content: MemoryContent {
154                raw: text.into(),
155                summary: None,
156                normalized: None,
157                language: Language::Zh,
158                content_type: ContentType::UserStatement,
159            },
160            understanding: MemoryUnderstanding {
161                entities: vec![EntityMention {
162                    text: entity.into(),
163                    canonical: entity.to_lowercase(),
164                    entity_type: EntityType::Other,
165                    span: None,
166                    confidence: UnitScore::new(0.8),
167                }],
168                events: vec![],
169                goals: vec![],
170                decisions: vec![],
171                preferences: vec![],
172                emotions: vec![],
173                causal_claims: vec![],
174                contradictions: vec![],
175                topics: vec![],
176                importance: UnitScore::new(0.5),
177                confidence: UnitScore::new(0.5),
178            },
179            context: WriteContext {
180                conversation_id: Some(1),
181                session_id: Some(1),
182                project_id: None,
183                task_id: None,
184                user_id: None,
185                local_time: Timestamp(1_700_000_000_000),
186                preceding_memory_ids: vec![],
187                source_refs: vec![],
188            },
189            semantic: SemanticSignature {
190                lexical_simhash: [1, 2, 3, 4],
191                dense_embedding_ref: None,
192                binary_code: [0, 0],
193                topic_minhash: [0u32; 16],
194            },
195        }
196    }
197
198    #[test]
199    fn raw_to_indexed_succeeds() {
200        let input = make_input(1, "Rust", "Rust");
201        let output = raw_to_indexed(
202            input,
203            &[],
204            &EdgeBuildParams::default(),
205            &AlgoParams::default(),
206        )
207        .unwrap();
208        assert_eq!(output.unit.stage, MemoryStage::Indexed);
209    }
210
211    #[test]
212    fn shared_entity_produces_links() {
213        let first = raw_to_indexed(
214            make_input(1, "Rust", "Rust"),
215            &[],
216            &EdgeBuildParams::default(),
217            &AlgoParams::default(),
218        )
219        .unwrap();
220        let second = raw_to_indexed(
221            make_input(2, "I also use Rust for systems programming", "Rust"),
222            &[first.unit],
223            &EdgeBuildParams::default(),
224            &AlgoParams::default(),
225        )
226        .unwrap();
227        assert!(!second.created_links.is_empty());
228    }
229}