Skip to main content

mempal_runtime/core/
utils.rs

1use std::collections::BTreeSet;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use sha2::{Digest, Sha256};
5
6use super::{
7    anchor,
8    types::{
9        AnchorKind, BootstrapIdentityParts, KnowledgeStatus, KnowledgeTier, MemoryDomain,
10        MemoryKind, Provenance, SourceType, TaxonomyEntry, TunnelEndpoint,
11    },
12};
13
14pub const DEFAULT_ROOM: &str = "default";
15const BOOTSTRAP_DRAWER_ID_HASH_LEN: usize = 12;
16
17pub fn build_drawer_id(wing: &str, room: Option<&str>, content: &str) -> String {
18    let room = room.unwrap_or(DEFAULT_ROOM);
19    let mut hasher = Sha256::new();
20    hasher.update(content.as_bytes());
21    let digest = format!("{:x}", hasher.finalize());
22
23    format!(
24        "drawer_{}_{}_{}",
25        sanitize_component(wing),
26        sanitize_component(room),
27        &digest[..8]
28    )
29}
30
31pub fn build_bootstrap_drawer_id(
32    wing: &str,
33    room: Option<&str>,
34    content: &str,
35    identity_components: &[String],
36) -> String {
37    let room = room.unwrap_or(DEFAULT_ROOM);
38    let mut hasher = Sha256::new();
39    hasher.update(content.as_bytes());
40    for component in identity_components {
41        hasher.update([0]);
42        hasher.update(component.as_bytes());
43    }
44    let digest = format!("{:x}", hasher.finalize());
45
46    format!(
47        "drawer_{}_{}_{}",
48        sanitize_component(wing),
49        sanitize_component(room),
50        &digest[..BOOTSTRAP_DRAWER_ID_HASH_LEN]
51    )
52}
53
54pub fn bootstrap_identity_components(parts: BootstrapIdentityParts<'_>) -> Vec<String> {
55    let supporting_refs = normalized_sorted_strings(parts.supporting_refs);
56    let counterexample_refs = normalized_sorted_strings(parts.counterexample_refs);
57    let teaching_refs = normalized_sorted_strings(parts.teaching_refs);
58    let verification_refs = normalized_sorted_strings(parts.verification_refs);
59    let mut components = vec![
60        format!("memory_kind={}", memory_kind_as_str(parts.memory_kind)),
61        format!("domain={}", memory_domain_as_str(parts.domain)),
62        format!("field={}", parts.field),
63        format!("anchor_kind={}", anchor_kind_as_str(parts.anchor_kind)),
64        format!("anchor_id={}", parts.anchor_id),
65        format!("parent_anchor_id={}", parts.parent_anchor_id.unwrap_or("")),
66        format!(
67            "provenance={}",
68            parts.provenance.map(provenance_as_str).unwrap_or("")
69        ),
70        format!("statement={}", parts.statement.unwrap_or("")),
71        format!(
72            "tier={}",
73            parts.tier.map(knowledge_tier_as_str).unwrap_or("")
74        ),
75        format!(
76            "status={}",
77            parts.status.map(knowledge_status_as_str).unwrap_or("")
78        ),
79        format!(
80            "scope_constraints={}",
81            parts.scope_constraints.unwrap_or("")
82        ),
83        format!("supporting_refs={}", supporting_refs.join(",")),
84        format!("counterexample_refs={}", counterexample_refs.join(",")),
85        format!("teaching_refs={}", teaching_refs.join(",")),
86        format!("verification_refs={}", verification_refs.join(",")),
87    ];
88
89    if let Some(trigger_hints) = parts.trigger_hints {
90        components.push(format!(
91            "intent_tags={}",
92            normalized_sorted_strings(&trigger_hints.intent_tags).join(",")
93        ));
94        components.push(format!(
95            "workflow_bias={}",
96            normalized_sorted_strings(&trigger_hints.workflow_bias).join(",")
97        ));
98        components.push(format!(
99            "tool_needs={}",
100            normalized_sorted_strings(&trigger_hints.tool_needs).join(",")
101        ));
102    }
103
104    components
105}
106
107pub fn build_bootstrap_drawer_id_from_parts(
108    wing: &str,
109    room: Option<&str>,
110    content: &str,
111    parts: BootstrapIdentityParts<'_>,
112) -> String {
113    build_bootstrap_drawer_id(wing, room, content, &bootstrap_identity_components(parts))
114}
115
116pub fn source_file_identity_component(source_file: Option<&str>) -> String {
117    format!(
118        "source_file={}",
119        source_file
120            .map(str::trim)
121            .filter(|value| !value.is_empty())
122            .unwrap_or("")
123    )
124}
125
126pub fn build_bootstrap_drawer_id_from_parts_with_source_file(
127    wing: &str,
128    room: Option<&str>,
129    content: &str,
130    parts: BootstrapIdentityParts<'_>,
131    source_file: Option<&str>,
132) -> String {
133    let mut components = bootstrap_identity_components(parts);
134    components.push(source_file_identity_component(source_file));
135    build_bootstrap_drawer_id(wing, room, content, &components)
136}
137
138pub fn build_bootstrap_evidence_drawer_id(
139    wing: &str,
140    room: Option<&str>,
141    content: &str,
142    source_type: &SourceType,
143    source_file: Option<&str>,
144) -> String {
145    let defaults = anchor::bootstrap_defaults(source_type);
146    let memory_kind = MemoryKind::Evidence;
147    let domain = MemoryDomain::Project;
148    let empty_refs: &[String] = &[];
149    build_bootstrap_drawer_id_from_parts_with_source_file(
150        wing,
151        room,
152        content,
153        BootstrapIdentityParts {
154            memory_kind: &memory_kind,
155            domain: &domain,
156            field: &defaults.field,
157            anchor_kind: &defaults.anchor_kind,
158            anchor_id: &defaults.anchor_id,
159            parent_anchor_id: defaults.parent_anchor_id.as_deref(),
160            provenance: Some(&defaults.provenance),
161            statement: None,
162            tier: None,
163            status: None,
164            supporting_refs: empty_refs,
165            counterexample_refs: empty_refs,
166            teaching_refs: empty_refs,
167            verification_refs: empty_refs,
168            scope_constraints: None,
169            trigger_hints: None,
170        },
171        source_file,
172    )
173}
174
175pub fn build_triple_id(subject: &str, predicate: &str, object: &str) -> String {
176    let mut hasher = Sha256::new();
177    hasher.update(subject.as_bytes());
178    hasher.update([0]);
179    hasher.update(predicate.as_bytes());
180    hasher.update([0]);
181    hasher.update(object.as_bytes());
182    let digest = format!("{:x}", hasher.finalize());
183
184    format!(
185        "triple_{}_{}_{}",
186        sanitize_component_prefix(subject, 8),
187        sanitize_component_prefix(predicate, 8),
188        &digest[..8]
189    )
190}
191
192pub fn build_tunnel_id(left: &TunnelEndpoint, right: &TunnelEndpoint) -> String {
193    let mut endpoints = [tunnel_endpoint_key(left), tunnel_endpoint_key(right)];
194    endpoints.sort();
195
196    let mut hasher = Sha256::new();
197    for component in [
198        endpoints[0].0.as_str(),
199        endpoints[0].1.as_str(),
200        endpoints[1].0.as_str(),
201        endpoints[1].1.as_str(),
202    ] {
203        hasher.update([0]);
204        hasher.update(component.as_bytes());
205    }
206    let digest = format!("{:x}", hasher.finalize());
207    format!("tunnel_{}", &digest[..16])
208}
209
210pub fn format_tunnel_endpoint(endpoint: &TunnelEndpoint) -> String {
211    match endpoint.room.as_deref() {
212        Some(room) if !room.is_empty() => format!("{}:{room}", endpoint.wing),
213        _ => endpoint.wing.clone(),
214    }
215}
216
217pub fn current_timestamp() -> String {
218    match SystemTime::now().duration_since(UNIX_EPOCH) {
219        Ok(duration) => duration.as_secs().to_string(),
220        Err(_) => "0".to_string(),
221    }
222}
223
224pub fn synthetic_source_file(drawer_id: &str) -> String {
225    format!("mempal://drawer/{drawer_id}")
226}
227
228pub fn knowledge_source_file(
229    domain: &MemoryDomain,
230    field: &str,
231    tier: &KnowledgeTier,
232    statement: &str,
233) -> String {
234    format!(
235        "knowledge://{}/{}/{}/{}",
236        enum_slug(memory_domain_as_str(domain)),
237        slugify_uri_component(field),
238        knowledge_tier_as_str(tier),
239        slugify_uri_component(statement)
240    )
241}
242
243pub fn source_file_or_synthetic(drawer_id: &str, source_file: Option<&str>) -> String {
244    source_file
245        .map(str::trim)
246        .filter(|value| !value.is_empty())
247        .map(ToOwned::to_owned)
248        .unwrap_or_else(|| synthetic_source_file(drawer_id))
249}
250
251pub fn route_room_from_taxonomy(content: &str, wing: &str, taxonomy: &[TaxonomyEntry]) -> String {
252    let normalized_content = content.to_lowercase();
253    let content_terms = content_terms(&normalized_content);
254
255    taxonomy
256        .iter()
257        .filter(|entry| entry.wing == wing)
258        .filter_map(|entry| {
259            let matched_keywords = matched_keywords(&normalized_content, &content_terms, entry);
260            (!matched_keywords.is_empty()).then_some((entry, matched_keywords))
261        })
262        .max_by(|(left_entry, left_matches), (right_entry, right_matches)| {
263            left_matches
264                .len()
265                .cmp(&right_matches.len())
266                .then_with(|| {
267                    left_matches
268                        .iter()
269                        .map(String::len)
270                        .sum::<usize>()
271                        .cmp(&right_matches.iter().map(String::len).sum::<usize>())
272                })
273                .then_with(|| left_entry.keywords.len().cmp(&right_entry.keywords.len()))
274        })
275        .map(|(entry, _)| {
276            if entry.room.trim().is_empty() {
277                DEFAULT_ROOM.to_string()
278            } else {
279                entry.room.clone()
280            }
281        })
282        .unwrap_or_else(|| DEFAULT_ROOM.to_string())
283}
284
285fn tunnel_endpoint_key(endpoint: &TunnelEndpoint) -> (String, String) {
286    (
287        endpoint.wing.trim().to_string(),
288        endpoint
289            .room
290            .as_deref()
291            .map(str::trim)
292            .filter(|room| !room.is_empty())
293            .unwrap_or("")
294            .to_string(),
295    )
296}
297
298fn sanitize_component(value: &str) -> String {
299    value
300        .chars()
301        .map(|ch| {
302            if ch.is_ascii_alphanumeric() {
303                ch.to_ascii_lowercase()
304            } else {
305                '_'
306            }
307        })
308        .collect()
309}
310
311fn sanitize_component_prefix(value: &str, max_len: usize) -> String {
312    let sanitized = sanitize_component(value);
313    let prefix: String = sanitized.chars().take(max_len).collect();
314    if prefix.is_empty() {
315        "x".to_string()
316    } else {
317        prefix
318    }
319}
320
321pub fn slugify_uri_component(value: &str) -> String {
322    let mut slug = String::new();
323    let mut prev_dash = false;
324
325    for ch in value.chars() {
326        if ch.is_ascii_alphanumeric() {
327            slug.push(ch.to_ascii_lowercase());
328            prev_dash = false;
329        } else if !prev_dash {
330            slug.push('-');
331            prev_dash = true;
332        }
333    }
334
335    let trimmed = slug.trim_matches('-');
336    if trimmed.is_empty() {
337        "x".to_string()
338    } else {
339        trimmed.to_string()
340    }
341}
342
343fn enum_slug(value: &str) -> String {
344    value.replace('_', "-")
345}
346
347fn memory_kind_as_str(kind: &MemoryKind) -> &'static str {
348    match kind {
349        MemoryKind::Evidence => "evidence",
350        MemoryKind::Knowledge => "knowledge",
351    }
352}
353
354fn memory_domain_as_str(domain: &MemoryDomain) -> &'static str {
355    match domain {
356        MemoryDomain::Project => "project",
357        MemoryDomain::Agent => "agent",
358        MemoryDomain::Skill => "skill",
359        MemoryDomain::Global => "global",
360    }
361}
362
363fn anchor_kind_as_str(kind: &AnchorKind) -> &'static str {
364    match kind {
365        AnchorKind::Global => "global",
366        AnchorKind::Repo => "repo",
367        AnchorKind::Worktree => "worktree",
368    }
369}
370
371fn provenance_as_str(provenance: &Provenance) -> &'static str {
372    match provenance {
373        Provenance::Runtime => "runtime",
374        Provenance::Research => "research",
375        Provenance::Human => "human",
376    }
377}
378
379fn knowledge_tier_as_str(tier: &KnowledgeTier) -> &'static str {
380    match tier {
381        KnowledgeTier::Qi => "qi",
382        KnowledgeTier::Shu => "shu",
383        KnowledgeTier::DaoRen => "dao_ren",
384        KnowledgeTier::DaoTian => "dao_tian",
385    }
386}
387
388fn knowledge_status_as_str(status: &KnowledgeStatus) -> &'static str {
389    match status {
390        KnowledgeStatus::Candidate => "candidate",
391        KnowledgeStatus::Promoted => "promoted",
392        KnowledgeStatus::Canonical => "canonical",
393        KnowledgeStatus::Demoted => "demoted",
394        KnowledgeStatus::Retired => "retired",
395    }
396}
397
398fn normalized_sorted_strings(values: &[String]) -> Vec<String> {
399    let mut normalized = values
400        .iter()
401        .map(|value| value.trim())
402        .filter(|value| !value.is_empty())
403        .map(ToOwned::to_owned)
404        .collect::<Vec<_>>();
405    normalized.sort();
406    normalized
407}
408
409fn matched_keywords(
410    normalized_content: &str,
411    content_terms: &BTreeSet<String>,
412    entry: &TaxonomyEntry,
413) -> Vec<String> {
414    entry
415        .keywords
416        .iter()
417        .map(|keyword| keyword.trim().to_lowercase())
418        .filter(|keyword| {
419            !keyword.is_empty()
420                && (content_terms.contains(keyword)
421                    || normalized_content.contains(keyword.as_str()))
422        })
423        .collect()
424}
425
426fn content_terms(content: &str) -> BTreeSet<String> {
427    content
428        .split(|ch: char| !ch.is_alphanumeric())
429        .filter(|term| !term.is_empty())
430        .map(ToOwned::to_owned)
431        .collect()
432}