Skip to main content

knowledge_runtime/adapters/
semantic_memory.rs

1use crate::error::RuntimeError;
2use crate::ids::{EntityId, Scope, ScopeKey};
3use crate::obs::trace::{RuntimeDerivedCandidateTraceV1, RuntimeQueryProvenanceV1, RuntimeView};
4use semantic_memory::{
5    ExplainedResult, MemoryStore, ProjectionClaimVersion, ProjectionEntityAlias, ProjectionEpisode,
6    ProjectionEvidenceRef, ProjectionImportLogEntry, ProjectionQuery, ProjectionRelationVersion,
7    ReceiptMode, SearchContext, SearchResult, SearchSource, SearchSourceType,
8    VectorSearchReceiptV1,
9};
10use stack_ids::{ClaimId, ClaimVersionId, TraceCtx};
11
12/// Read-only adapter over a `semantic_memory::MemoryStore`.
13///
14/// This adapter exposes only the query surface of `MemoryStore`. It
15/// intentionally does NOT expose mutation methods — the runtime owns
16/// derived projections, never source truth.
17#[derive(Clone)]
18pub struct SemanticMemoryAdapter {
19    store: MemoryStore,
20}
21
22#[derive(Debug, Clone)]
23pub struct ExplainedSearchArtifacts {
24    pub explained_results: Vec<ExplainedResult>,
25    pub provenance: RuntimeQueryProvenanceV1,
26}
27
28impl SemanticMemoryAdapter {
29    /// Wrap an existing `MemoryStore`.
30    pub fn new(store: MemoryStore) -> Self {
31        Self { store }
32    }
33
34    /// Hybrid search with scope applied as namespace filter.
35    pub async fn search(
36        &self,
37        query: &str,
38        scope: &Scope,
39        limit: Option<usize>,
40        source_types: Option<&[SearchSourceType]>,
41    ) -> Result<Vec<SearchResult>, RuntimeError> {
42        let ns = scope.namespace.as_str();
43        let ns_slice: &[&str] = &[ns];
44        let scope_key = scope.key();
45        let filterable_scope =
46            scope.domain.is_some() || scope.workspace_id.is_some() || scope.repo_id.is_some();
47        let scoped_source_types = if filterable_scope {
48            Some(filterable_search_source_types(source_types))
49        } else {
50            source_types.map(|types| types.to_vec())
51        };
52        let requested_limit = limit.unwrap_or(10);
53        let candidate_limit = if filterable_scope {
54            Some(requested_limit.saturating_mul(3).max(requested_limit))
55        } else {
56            limit
57        };
58
59        let results = self
60            .store
61            .search(
62                query,
63                candidate_limit,
64                Some(ns_slice),
65                scoped_source_types.as_deref(),
66            )
67            .await
68            .map_err(RuntimeError::Memory)?;
69
70        if filterable_scope {
71            let filtered = self
72                .store
73                .filter_search_results_by_scope(results, &scope_key)
74                .await
75                .map_err(RuntimeError::Memory)?;
76            Ok(filtered.into_iter().take(requested_limit).collect())
77        } else {
78            Ok(results)
79        }
80    }
81
82    /// Hybrid search that requests semantic-memory receipt metadata and returns
83    /// a runtime-safe derived candidate summary when available.
84    pub async fn search_with_receipt(
85        &self,
86        query: &str,
87        scope: &Scope,
88        limit: Option<usize>,
89        source_types: Option<&[SearchSourceType]>,
90    ) -> Result<(Vec<SearchResult>, Option<RuntimeDerivedCandidateTraceV1>), RuntimeError> {
91        let ns = scope.namespace.as_str();
92        let ns_slice: &[&str] = &[ns];
93        let scope_key = scope.key();
94        let filterable_scope =
95            scope.domain.is_some() || scope.workspace_id.is_some() || scope.repo_id.is_some();
96        let scoped_source_types = if filterable_scope {
97            Some(filterable_search_source_types(source_types))
98        } else {
99            source_types.map(|types| types.to_vec())
100        };
101        let requested_limit = limit.unwrap_or(10);
102        let candidate_limit = if filterable_scope {
103            requested_limit.saturating_mul(3).max(requested_limit)
104        } else {
105            requested_limit
106        };
107        let mut context = SearchContext::default_now();
108        context.receipt_mode = ReceiptMode::ReturnReceipt;
109
110        let response = self
111            .store
112            .search_explained_with_context(
113                query,
114                Some(candidate_limit),
115                Some(ns_slice),
116                scoped_source_types.as_deref(),
117                context,
118            )
119            .await
120            .map_err(RuntimeError::Memory)?;
121        let receipt = response.receipt.as_ref().map(runtime_trace_from_receipt);
122
123        let explained_results = if filterable_scope {
124            let filtered = self
125                .store
126                .filter_search_results_by_scope(
127                    response
128                        .results
129                        .iter()
130                        .map(|item| item.result.clone())
131                        .collect(),
132                    &scope_key,
133                )
134                .await
135                .map_err(RuntimeError::Memory)?;
136            let allowed: std::collections::BTreeSet<String> =
137                filtered.iter().map(search_identity).collect();
138            response
139                .results
140                .into_iter()
141                .filter(|item| allowed.contains(&search_identity(&item.result)))
142                .take(requested_limit)
143                .collect::<Vec<_>>()
144        } else {
145            response.results.into_iter().take(requested_limit).collect()
146        };
147
148        Ok((
149            explained_results
150                .into_iter()
151                .map(|item| item.result)
152                .collect(),
153            receipt,
154        ))
155    }
156
157    /// Hybrid search with explicit explained-score payloads and runtime-owned provenance.
158    pub async fn search_explained(
159        &self,
160        query: &str,
161        scope: &Scope,
162        limit: usize,
163        relevance_threshold: f64,
164    ) -> Result<ExplainedSearchArtifacts, RuntimeError> {
165        let ns = scope.namespace.as_str();
166        let ns_slice: &[&str] = &[ns];
167        let scope_key = scope.key();
168        let filterable_scope =
169            scope.domain.is_some() || scope.workspace_id.is_some() || scope.repo_id.is_some();
170        let candidate_limit = if filterable_scope {
171            limit.saturating_mul(3).max(limit)
172        } else {
173            limit
174        };
175
176        let explained_results = self
177            .store
178            .search_explained(query, Some(candidate_limit), Some(ns_slice), None)
179            .await
180            .map_err(RuntimeError::Memory)?;
181        let pre_filter_count = explained_results.len();
182
183        let scoped_results = if filterable_scope {
184            let filtered = self
185                .store
186                .filter_search_results_by_scope(
187                    explained_results
188                        .iter()
189                        .map(|item| item.result.clone())
190                        .collect(),
191                    &scope_key,
192                )
193                .await
194                .map_err(RuntimeError::Memory)?;
195            let allowed: std::collections::BTreeSet<String> =
196                filtered.iter().map(search_identity).collect();
197            explained_results
198                .into_iter()
199                .filter(|item| allowed.contains(&search_identity(&item.result)))
200                .collect::<Vec<_>>()
201        } else {
202            explained_results
203        };
204        let post_filter_count = scoped_results.len();
205
206        let filtered: Vec<_> = scoped_results
207            .iter()
208            .filter(|item| item.result.score >= relevance_threshold)
209            .take(limit)
210            .cloned()
211            .collect();
212
213        let mut adaptive_threshold_used = false;
214        let mut fallback_threshold_val = None;
215        let final_results = if filtered.len() < 2 {
216            let fallback_threshold = relevance_threshold / 2.0;
217            let fallback: Vec<_> = scoped_results
218                .iter()
219                .filter(|item| item.result.score >= fallback_threshold)
220                .take(limit)
221                .cloned()
222                .collect();
223            if fallback.len() > filtered.len() {
224                adaptive_threshold_used = true;
225                fallback_threshold_val = Some(fallback_threshold);
226                fallback
227            } else {
228                filtered
229            }
230        } else {
231            filtered
232        };
233
234        let kernel_degraded_reason = if adaptive_threshold_used {
235            Some(format!(
236                "adaptive_threshold: primary {:.3} fell back to {:.3}",
237                relevance_threshold,
238                fallback_threshold_val.unwrap_or(0.0)
239            ))
240        } else if final_results.is_empty() {
241            Some("zero_results: no relevant chunks found".into())
242        } else {
243            None
244        };
245
246        Ok(ExplainedSearchArtifacts {
247            explained_results: final_results,
248            provenance: RuntimeQueryProvenanceV1 {
249                schema_version: "runtime_query_provenance_v1".into(),
250                trace_ctx: TraceCtx::generate(),
251                scope: scope_key,
252                classification_mode: "search".into(),
253                classification_reason: None,
254                query: query.to_string(),
255                requested_views: vec![RuntimeView::Semantic],
256                executed_views: vec![RuntimeView::Semantic],
257                widenings: Vec::new(),
258                warnings: Vec::new(),
259                kernel_degraded_reason,
260                leg_strategies: vec!["semantic_memory.search_explained".into()],
261                leg_timings_ms: Vec::new(),
262                total_duration_ms: 0,
263                raw_result_count: pre_filter_count,
264                merged_result_count: post_filter_count,
265                duplicates_fused: 0,
266                valid_as_of: None,
267                recorded_as_of: None,
268                temporal_mode: None,
269            },
270        })
271    }
272
273    /// Query imported projection rows by free-text content.
274    pub async fn search_projection_text(
275        &self,
276        query: &str,
277        scope: &Scope,
278        limit: usize,
279    ) -> Result<Vec<SearchResult>, RuntimeError> {
280        let mut projection_query = ProjectionQuery::new(scope.key());
281        projection_query.text_query = Some(query.to_string());
282        projection_query.limit = limit;
283
284        let claims = self
285            .store
286            .query_claim_versions(projection_query.clone())
287            .await
288            .map_err(RuntimeError::Memory)?;
289        let relations = self
290            .store
291            .query_relation_versions(projection_query.clone())
292            .await
293            .map_err(RuntimeError::Memory)?;
294        let episodes = self
295            .store
296            .query_episodes(projection_query)
297            .await
298            .map_err(RuntimeError::Memory)?;
299
300        Ok(merge_projection_results(claims, relations, episodes, limit))
301    }
302
303    /// Query imported projection rows with explicit bitemporal filters.
304    ///
305    /// This is the explicit form used by callers that need independent
306    /// `valid_at` and `recorded_at_or_before` semantics.
307    pub async fn search_projection_temporal_with_recorded_at(
308        &self,
309        query: &str,
310        scope: &Scope,
311        valid_at: &str,
312        recorded_at_or_before: &str,
313        limit: usize,
314    ) -> Result<Vec<SearchResult>, RuntimeError> {
315        let mut projection_query = ProjectionQuery::new(scope.key());
316        projection_query.text_query = Some(query.to_string());
317        projection_query.valid_at = Some(valid_at.to_string());
318        projection_query.recorded_at_or_before = Some(recorded_at_or_before.to_string());
319        projection_query.limit = limit;
320
321        let claims = self
322            .store
323            .query_claim_versions(projection_query.clone())
324            .await
325            .map_err(RuntimeError::Memory)?;
326        let relations = self
327            .store
328            .query_relation_versions(projection_query.clone())
329            .await
330            .map_err(RuntimeError::Memory)?;
331        let episodes = self
332            .store
333            .query_episodes(projection_query)
334            .await
335            .map_err(RuntimeError::Memory)?;
336
337        Ok(merge_projection_results(claims, relations, episodes, limit))
338    }
339
340    /// Query imported projection rows with temporal filters.
341    ///
342    /// This convenience form keeps the existing route behavior by using
343    /// the same `valid_at` value for both valid-time and transaction-time
344    /// cutoffs.
345    pub async fn search_projection_temporal(
346        &self,
347        query: &str,
348        scope: &Scope,
349        valid_at: &str,
350        limit: usize,
351    ) -> Result<Vec<SearchResult>, RuntimeError> {
352        self.search_projection_temporal_with_recorded_at(query, scope, valid_at, valid_at, limit)
353            .await
354    }
355
356    /// Query imported claim/relation rows for a bounded set of entity candidates.
357    pub async fn search_projection_by_entity_ids(
358        &self,
359        scope: &Scope,
360        entity_ids: &[EntityId],
361        limit: usize,
362    ) -> Result<Vec<SearchResult>, RuntimeError> {
363        let mut all_claims = Vec::new();
364        let mut all_relations = Vec::new();
365
366        for entity_id in entity_ids.iter().take(limit.max(1)) {
367            let mut projection_query = ProjectionQuery::new(scope.key());
368            projection_query.subject_entity_id = Some(entity_id.clone());
369            projection_query.limit = limit;
370
371            all_claims.extend(
372                self.store
373                    .query_claim_versions(projection_query.clone())
374                    .await
375                    .map_err(RuntimeError::Memory)?,
376            );
377            all_relations.extend(
378                self.store
379                    .query_relation_versions(projection_query)
380                    .await
381                    .map_err(RuntimeError::Memory)?,
382            );
383        }
384
385        Ok(merge_projection_results(
386            all_claims,
387            all_relations,
388            Vec::new(),
389            limit,
390        ))
391    }
392
393    /// Query imported claim/relation rows for a bounded set of entity candidates
394    /// with explicit bitemporal filters.
395    pub async fn search_projection_by_entity_ids_with_recorded_at(
396        &self,
397        scope: &Scope,
398        entity_ids: &[EntityId],
399        valid_at: &str,
400        recorded_at_or_before: &str,
401        limit: usize,
402    ) -> Result<Vec<SearchResult>, RuntimeError> {
403        let mut all_claims = Vec::new();
404        let mut all_relations = Vec::new();
405
406        for entity_id in entity_ids.iter().take(limit.max(1)) {
407            let mut projection_query = ProjectionQuery::new(scope.key());
408            projection_query.subject_entity_id = Some(entity_id.clone());
409            projection_query.valid_at = Some(valid_at.to_string());
410            projection_query.recorded_at_or_before = Some(recorded_at_or_before.to_string());
411            projection_query.limit = limit;
412
413            all_claims.extend(
414                self.store
415                    .query_claim_versions(projection_query.clone())
416                    .await
417                    .map_err(RuntimeError::Memory)?,
418            );
419            all_relations.extend(
420                self.store
421                    .query_relation_versions(projection_query)
422                    .await
423                    .map_err(RuntimeError::Memory)?,
424            );
425        }
426
427        Ok(merge_projection_results(
428            all_claims,
429            all_relations,
430            Vec::new(),
431            limit,
432        ))
433    }
434
435    /// Query bounded alias candidates from imported projection rows.
436    pub async fn query_alias_candidates(
437        &self,
438        mention: &str,
439        scope: &Scope,
440        limit: usize,
441    ) -> Result<Vec<ProjectionEntityAlias>, RuntimeError> {
442        let mut projection_query = ProjectionQuery::new(scope.key());
443        projection_query.limit = limit.max(8).saturating_mul(8).min(64);
444
445        let mut aliases = self
446            .store
447            .query_entity_aliases(projection_query)
448            .await
449            .map_err(RuntimeError::Memory)?;
450        aliases.sort_by(|a, b| {
451            alias_candidate_score(mention, b)
452                .cmp(&alias_candidate_score(mention, a))
453                .then_with(|| b.is_human_confirmed_final.cmp(&a.is_human_confirmed_final))
454                .then_with(|| b.recorded_at.cmp(&a.recorded_at))
455        });
456        aliases.retain(|alias| alias_candidate_score(mention, alias) > 0);
457        aliases.truncate(limit);
458        Ok(aliases)
459    }
460
461    /// Query bounded alias candidates from imported projection rows with explicit
462    /// temporal filters.
463    pub async fn query_alias_candidates_with_recorded_at(
464        &self,
465        mention: &str,
466        scope: &Scope,
467        valid_at: &str,
468        recorded_at_or_before: &str,
469        limit: usize,
470    ) -> Result<Vec<ProjectionEntityAlias>, RuntimeError> {
471        let mut projection_query = ProjectionQuery::new(scope.key());
472        projection_query.valid_at = Some(valid_at.to_string());
473        projection_query.recorded_at_or_before = Some(recorded_at_or_before.to_string());
474        projection_query.limit = limit.max(8).saturating_mul(8).min(64);
475
476        let mut aliases = self
477            .store
478            .query_entity_aliases(projection_query)
479            .await
480            .map_err(RuntimeError::Memory)?;
481        aliases.sort_by(|a, b| {
482            alias_candidate_score(mention, b)
483                .cmp(&alias_candidate_score(mention, a))
484                .then_with(|| b.is_human_confirmed_final.cmp(&a.is_human_confirmed_final))
485                .then_with(|| b.recorded_at.cmp(&a.recorded_at))
486        });
487        aliases.retain(|alias| alias_candidate_score(mention, alias) > 0);
488        aliases.truncate(limit);
489        Ok(aliases)
490    }
491
492    /// Query imported evidence-reference rows for a specific claim.
493    ///
494    /// This is an explicit explain/audit entrypoint: raw evidence handles are
495    /// returned as opaque `ProjectionEvidenceRef` rows and are not merged into
496    /// normal search payloads.
497    pub async fn query_evidence_refs_for_claim(
498        &self,
499        claim_id: &str,
500        claim_version_id: Option<&str>,
501        scope: &Scope,
502        recorded_at_or_before: Option<&str>,
503        limit: usize,
504    ) -> Result<Vec<ProjectionEvidenceRef>, RuntimeError> {
505        let mut projection_query = ProjectionQuery::new(scope.key());
506        projection_query.limit = limit.max(1);
507        projection_query.claim_id = Some(ClaimId::new(claim_id));
508        projection_query.claim_version_id = claim_version_id.map(ClaimVersionId::new);
509        projection_query.recorded_at_or_before = recorded_at_or_before.map(str::to_string);
510
511        self.store
512            .query_evidence_refs(projection_query)
513            .await
514            .map_err(RuntimeError::Memory)
515    }
516
517    /// Query imported claim rows for a specific claim or claim version.
518    pub async fn query_claim_versions_for_claim(
519        &self,
520        claim_id: &str,
521        claim_version_id: Option<&str>,
522        scope: &Scope,
523        recorded_at_or_before: Option<&str>,
524        limit: usize,
525    ) -> Result<Vec<ProjectionClaimVersion>, RuntimeError> {
526        let mut projection_query = ProjectionQuery::new(scope.key());
527        projection_query.limit = limit.max(1);
528        projection_query.claim_id = Some(ClaimId::new(claim_id));
529        projection_query.claim_version_id = claim_version_id.map(ClaimVersionId::new);
530        projection_query.recorded_at_or_before = recorded_at_or_before.map(str::to_string);
531
532        self.store
533            .query_claim_versions(projection_query)
534            .await
535            .map_err(RuntimeError::Memory)
536    }
537
538    /// Get the underlying store reference for advanced read operations
539    /// (graph traversal, explained search, etc.).
540    pub fn store(&self) -> &MemoryStore {
541        &self.store
542    }
543
544    /// Query the last successful import timestamp for a namespace.
545    ///
546    /// Returns `None` if no imports have ever been recorded for this namespace.
547    /// Used by the runtime to detect import staleness and emit warnings.
548    pub async fn last_import_at(&self, namespace: &str) -> Result<Option<String>, RuntimeError> {
549        self.store
550            .last_import_at(namespace)
551            .await
552            .map_err(RuntimeError::Memory)
553    }
554
555    /// Return the most recent import receipt carrying a rebuildable kernel payload.
556    pub async fn latest_kernel_projection_import(
557        &self,
558        scope_key: &ScopeKey,
559    ) -> Result<Option<ProjectionImportLogEntry>, RuntimeError> {
560        self.store
561            .latest_rebuildable_kernel_projection_import_for_scope(scope_key)
562            .await
563            .map_err(RuntimeError::Memory)
564    }
565}
566
567fn filterable_search_source_types(
568    source_types: Option<&[SearchSourceType]>,
569) -> Vec<SearchSourceType> {
570    let mut filtered = source_types
571        .map(|types| {
572            types
573                .iter()
574                .filter(|source_type| {
575                    matches!(
576                        source_type,
577                        SearchSourceType::Chunks | SearchSourceType::Episodes
578                    )
579                })
580                .cloned()
581                .collect::<Vec<_>>()
582        })
583        .unwrap_or_else(|| vec![SearchSourceType::Chunks, SearchSourceType::Episodes]);
584    if filtered.is_empty() {
585        filtered = vec![SearchSourceType::Chunks, SearchSourceType::Episodes];
586    }
587    filtered
588}
589
590fn merge_projection_results(
591    claims: Vec<ProjectionClaimVersion>,
592    relations: Vec<ProjectionRelationVersion>,
593    episodes: Vec<ProjectionEpisode>,
594    limit: usize,
595) -> Vec<SearchResult> {
596    let mut results = Vec::new();
597    let total = claims.len() + relations.len() + episodes.len();
598    if total == 0 {
599        return results;
600    }
601
602    for (idx, claim) in claims.into_iter().enumerate() {
603        results.push(SearchResult {
604            content: claim.content.clone(),
605            source: SearchSource::Projection {
606                projection_kind: "claim_version".into(),
607                projection_id: claim.claim_version_id.as_str().to_string(),
608                scope_key: claim.scope_key.clone(),
609                valid_from: claim.valid_from.clone(),
610                valid_to: claim.valid_to.clone(),
611                recorded_at: claim.recorded_at.clone(),
612                source_envelope_id: claim.source_envelope_id.as_str().to_string(),
613                source_authority: claim.source_authority.clone(),
614            },
615            score: projection_rank_score(idx, total),
616            bm25_rank: None,
617            vector_rank: None,
618            cosine_similarity: None,
619        });
620    }
621
622    for (idx, relation) in relations.into_iter().enumerate() {
623        results.push(SearchResult {
624            content: format!(
625                "{} {} {}",
626                relation.subject_entity_id.as_str(),
627                relation.predicate,
628                relation.object_anchor
629            ),
630            source: SearchSource::Projection {
631                projection_kind: "relation_version".into(),
632                projection_id: relation.relation_version_id.as_str().to_string(),
633                scope_key: relation.scope_key.clone(),
634                valid_from: relation.valid_from.clone(),
635                valid_to: relation.valid_to.clone(),
636                recorded_at: relation.recorded_at.clone(),
637                source_envelope_id: relation.source_envelope_id.as_str().to_string(),
638                source_authority: relation.source_authority.clone(),
639            },
640            score: projection_rank_score(idx, total),
641            bm25_rank: None,
642            vector_rank: None,
643            cosine_similarity: None,
644        });
645    }
646
647    for (idx, episode) in episodes.into_iter().enumerate() {
648        results.push(SearchResult {
649            content: format!(
650                "{} {} {}",
651                episode.document_id, episode.effect_type, episode.outcome
652            ),
653            source: SearchSource::Projection {
654                projection_kind: "episode".into(),
655                projection_id: episode.episode_id.as_str().to_string(),
656                scope_key: episode.scope_key.clone(),
657                valid_from: None,
658                valid_to: None,
659                recorded_at: episode.recorded_at.clone(),
660                source_envelope_id: episode.source_envelope_id.as_str().to_string(),
661                source_authority: episode.source_authority.clone(),
662            },
663            score: projection_rank_score(idx, total),
664            bm25_rank: None,
665            vector_rank: None,
666            cosine_similarity: None,
667        });
668    }
669
670    results.sort_by(|a, b| {
671        b.score
672            .partial_cmp(&a.score)
673            .unwrap_or(std::cmp::Ordering::Equal)
674    });
675    results.truncate(limit);
676    results
677}
678
679fn projection_rank_score(index: usize, total: usize) -> f64 {
680    let denom = total.max(1) as f64;
681    (denom - index as f64) / denom
682}
683
684fn alias_candidate_score(mention: &str, alias: &ProjectionEntityAlias) -> usize {
685    let mention = mention.trim().to_lowercase();
686    if mention.is_empty() {
687        return 0;
688    }
689
690    let alias_text = alias.alias_text.to_lowercase();
691    let canonical = alias.canonical_entity_id.as_str().to_lowercase();
692    if alias_text == mention {
693        return 400;
694    }
695    if alias_text.starts_with(&mention) || alias_text.contains(&mention) {
696        return 300;
697    }
698    if canonical == mention || canonical.starts_with(&mention) {
699        return 200;
700    }
701
702    let alias_distance = levenshtein_distance(&mention, &alias_text);
703    if alias_distance <= 2 {
704        return 100usize.saturating_sub(alias_distance * 20);
705    }
706
707    0
708}
709
710fn levenshtein_distance(left: &str, right: &str) -> usize {
711    let left: Vec<char> = left.chars().collect();
712    let right: Vec<char> = right.chars().collect();
713    let mut costs: Vec<usize> = (0..=right.len()).collect();
714
715    for (i, left_char) in left.iter().enumerate() {
716        let mut last_cost = i;
717        costs[0] = i + 1;
718        for (j, right_char) in right.iter().enumerate() {
719            let new_cost = if left_char == right_char {
720                last_cost
721            } else {
722                1 + last_cost.min(costs[j]).min(costs[j + 1])
723            };
724            last_cost = costs[j + 1];
725            costs[j + 1] = new_cost;
726        }
727    }
728
729    costs[right.len()]
730}
731
732impl std::fmt::Debug for SemanticMemoryAdapter {
733    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
734        f.debug_struct("SemanticMemoryAdapter")
735            .field("store", &"<MemoryStore>")
736            .finish()
737    }
738}
739
740fn search_identity(result: &SearchResult) -> String {
741    serde_json::to_string(&result.source).unwrap_or_else(|_| format!("{:?}", result.source))
742}
743
744fn runtime_trace_from_receipt(receipt: &VectorSearchReceiptV1) -> RuntimeDerivedCandidateTraceV1 {
745    RuntimeDerivedCandidateTraceV1 {
746        candidate_backend: receipt.candidate_backend.clone(),
747        codec_family: receipt.codec_family.clone(),
748        generation_id: receipt.artifact_generation_id.clone(),
749        embedding_snapshot_digest: None,
750        pool_manifest_digest: receipt.vector_artifact_manifest_digest.clone(),
751        exact_rerank: receipt.exact_rerank,
752        approximate: receipt.approximate,
753        fallback: receipt.fallback.clone(),
754        raw_candidate_count: receipt.returned_candidates,
755        post_filter_count: receipt.post_filter_candidates,
756        final_result_count: receipt.result_ids.len(),
757    }
758}