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#[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 pub fn new(store: MemoryStore) -> Self {
31 Self { store }
32 }
33
34 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 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 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 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 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 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 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 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 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 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 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 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 pub fn store(&self) -> &MemoryStore {
541 &self.store
542 }
543
544 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 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}