Skip to main content

hippmem_write/
staged.rs

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