1use crate::adapters::semantic_memory::SemanticMemoryAdapter;
2use crate::config::{RuntimeCandidateBackendHint, RuntimeConfig};
3use crate::entity::registry::{Entity, EntityRegistry};
4use crate::error::RuntimeError;
5use crate::ids::{ProjectionId, ProjectionKind, Scope, ScopeKey};
6use crate::inference::InferenceExplanation;
7use crate::obs::trace::{QueryTrace, QueryWarning, RuntimeDerivedCandidateTraceV1};
8use crate::projection::lifecycle::{
9 InvalidationEvent, ProjectionActionResult, ProjectionHealth, ProjectionTracker,
10 ProjectionVersion, StaleCause,
11};
12use crate::query::classify::{self, ClassifyResult, QueryMode};
13use crate::query::merge::{self, LegResult, MergePolicy};
14use crate::query::route::{self, RetrievalStrategy, RoutePlan};
15use semantic_memory::{ProjectionClaimVersion, ProjectionEvidenceRef, SearchResult};
16use serde::{Deserialize, Serialize};
17use stack_ids::TraceCtx;
18use std::time::Instant;
19
20const EXPLICIT_TEMPORAL_LABEL: &str = "explicit bitemporal filters";
21
22fn force_explicit_temporal_classification(classification: ClassifyResult) -> ClassifyResult {
23 let temporal_component = QueryMode::TemporalLookup {
24 temporal_expr: EXPLICIT_TEMPORAL_LABEL.into(),
25 };
26
27 let mode = match classification.mode {
28 QueryMode::TemporalLookup { .. } => QueryMode::TemporalLookup {
29 temporal_expr: EXPLICIT_TEMPORAL_LABEL.into(),
30 },
31 QueryMode::SemanticLookup => temporal_component,
32 QueryMode::EntityLookup { mention } => QueryMode::Mixed {
33 components: vec![QueryMode::EntityLookup { mention }, temporal_component],
34 },
35 QueryMode::Mixed { mut components } => {
36 let has_temporal = components
37 .iter()
38 .any(|component| matches!(component, QueryMode::TemporalLookup { .. }));
39 if !has_temporal {
40 components.push(temporal_component);
41 }
42 QueryMode::Mixed { components }
43 }
44 };
45
46 ClassifyResult {
47 mode,
48 confidence: classification.confidence,
49 reason: Some("explicit temporal query requested".into()),
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ProjectedVerificationLifecycle {
57 Unverified,
58 Verified,
59 Contradicted,
60 Superseded,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(tag = "state", rename_all = "snake_case")]
66pub enum ProjectedPromotionState {
67 NotPromoted,
68 Eligible,
69 Blocked {
70 reason: String,
71 },
72 Promoted {
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 version_id: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 promoted_at: Option<String>,
77 },
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct ProjectedVerificationSummary {
83 pub claim_id: String,
84 pub claim_version_id: String,
85 pub lifecycle_state: ProjectedVerificationLifecycle,
86 pub promotion_state: ProjectedPromotionState,
87 pub claim_state: String,
88 pub freshness: String,
89 pub contradiction_status: String,
90 pub completed_trial_count: u32,
91 pub passed_refutation_count: u32,
92 pub failed_refutation_count: u32,
93 pub comparability_snapshot_version: Option<String>,
94 pub notes: Vec<String>,
95 pub source_envelope_id: String,
96 pub recorded_at: String,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100struct MetadataVerificationSummary {
101 lifecycle_state: ProjectedVerificationLifecycle,
102 promotion_state: ProjectedPromotionState,
103 completed_trial_count: u32,
104 passed_refutation_count: u32,
105 failed_refutation_count: u32,
106 #[serde(default)]
107 comparability_snapshot_version: Option<String>,
108 #[serde(default)]
109 notes: Vec<String>,
110}
111
112pub struct KnowledgeRuntime {
166 adapter: SemanticMemoryAdapter,
167 config: RuntimeConfig,
168 entity_registry: EntityRegistry,
169 projection_tracker: ProjectionTracker,
170}
171
172impl KnowledgeRuntime {
173 pub fn new(
178 mut config: RuntimeConfig,
179 adapter: SemanticMemoryAdapter,
180 ) -> Result<Self, RuntimeError> {
181 config.normalize_and_validate()?;
182
183 let entity_registry =
184 EntityRegistry::new(config.entity.max_aliases, config.entity.max_entities);
185 let projection_tracker = ProjectionTracker::new(config.projection.staleness_threshold_secs);
186
187 Ok(Self {
188 adapter,
189 config,
190 entity_registry,
191 projection_tracker,
192 })
193 }
194
195 pub(crate) fn adapter_ref(&self) -> &SemanticMemoryAdapter {
196 &self.adapter
197 }
198
199 pub(crate) fn default_scope(&self) -> &Scope {
200 &self.config.default_scope
201 }
202
203 pub async fn query(
213 &self,
214 query: &str,
215 scope: Option<&Scope>,
216 ) -> Result<(Vec<SearchResult>, QueryTrace), RuntimeError> {
217 self.query_with_trace(query, scope, None).await
218 }
219
220 pub async fn query_with_trace(
233 &self,
234 query: &str,
235 scope: Option<&Scope>,
236 trace_ctx: Option<TraceCtx>,
237 ) -> Result<(Vec<SearchResult>, QueryTrace), RuntimeError> {
238 let start = Instant::now();
239 let trace_ctx = trace_ctx.unwrap_or_else(TraceCtx::generate);
240 let scope = scope.unwrap_or(&self.config.default_scope);
241 let scope_key = scope.key();
242 let mut warnings = Vec::new();
243 let mut last_import_at = None;
244 let has_projection_imports = match self.adapter.last_import_at(&scope.namespace).await {
245 Ok(ts) => {
246 last_import_at = ts.clone();
247 ts.is_some()
248 }
249 Err(e) => {
250 tracing::warn!(
251 namespace = %scope.namespace,
252 error = %e,
253 "failed to check import freshness"
254 );
255 false
256 }
257 };
258 if self.config.projection.import_staleness_threshold_secs > 0 {
259 if let Some(ts) = last_import_at {
260 if let Ok(imported_at) = chrono::DateTime::parse_from_rfc3339(&ts) {
261 let age = chrono::Utc::now()
262 .signed_duration_since(imported_at)
263 .num_seconds()
264 .unsigned_abs();
265 if age > self.config.projection.import_staleness_threshold_secs {
266 warnings.push(QueryWarning::ProjectionImportStale {
267 scope: scope_key.clone(),
268 last_import_at: Some(ts),
269 });
270 }
271 }
272 }
273 }
274
275 let classification = classify::classify(query);
277
278 let plan = route::plan(
280 query,
281 &classification.mode,
282 scope,
283 self.config.query.default_limit,
284 );
285
286 let (leg_results, leg_timings, derived_candidate_receipts) = self
288 .execute_plan(
289 &plan,
290 &scope_key,
291 has_projection_imports,
292 &mut warnings,
293 None,
294 )
295 .await?;
296
297 let policy = MergePolicy {
299 limit: self.config.query.default_limit,
300 ..Default::default()
301 };
302 let merged = merge::merge(leg_results, &policy);
303
304 let total_duration = start.elapsed();
305
306 let mut trace = QueryTrace::from_pipeline(
308 trace_ctx,
309 scope_key,
310 classification,
311 plan,
312 leg_timings,
313 total_duration,
314 &merged,
315 warnings,
316 );
317 trace.derived_candidate_receipts = derived_candidate_receipts;
318
319 let results = merged.results.into_iter().map(|m| m.result).collect();
320 Ok((results, trace))
321 }
322
323 pub async fn query_with_inference_explanation(
326 &self,
327 query: &str,
328 scope: Option<&Scope>,
329 trace_ctx: Option<TraceCtx>,
330 ) -> Result<(Vec<SearchResult>, QueryTrace, Option<InferenceExplanation>), RuntimeError> {
331 let (results, mut trace) = self.query_with_trace(query, scope, trace_ctx).await?;
332 let explanation = self.latest_inference_explanation(scope).await?;
333 trace.kernel_degraded_reason = explanation
334 .as_ref()
335 .and_then(|value| value.degraded_reason.clone());
336 Ok((results, trace, explanation))
337 }
338
339 pub async fn query_temporal(
349 &self,
350 query: &str,
351 scope: Option<&Scope>,
352 valid_at: &str,
353 recorded_at_or_before: &str,
354 ) -> Result<(Vec<SearchResult>, QueryTrace), RuntimeError> {
355 self.query_temporal_with_trace(query, scope, None, valid_at, recorded_at_or_before)
356 .await
357 }
358
359 pub async fn query_temporal_with_trace(
363 &self,
364 query: &str,
365 scope: Option<&Scope>,
366 trace_ctx: Option<TraceCtx>,
367 valid_at: &str,
368 recorded_at_or_before: &str,
369 ) -> Result<(Vec<SearchResult>, QueryTrace), RuntimeError> {
370 let start = Instant::now();
371 let trace_ctx = trace_ctx.unwrap_or_else(TraceCtx::generate);
372 let scope = scope.unwrap_or(&self.config.default_scope);
373 let scope_key = scope.key();
374 let mut warnings = Vec::new();
375 let mut last_import_at = None;
376 let has_projection_imports = match self.adapter.last_import_at(&scope.namespace).await {
377 Ok(ts) => {
378 last_import_at = ts.clone();
379 ts.is_some()
380 }
381 Err(e) => {
382 tracing::warn!(
383 namespace = %scope.namespace,
384 error = %e,
385 "failed to check import freshness"
386 );
387 false
388 }
389 };
390 if self.config.projection.import_staleness_threshold_secs > 0 {
391 if let Some(ts) = last_import_at {
392 if let Ok(imported_at) = chrono::DateTime::parse_from_rfc3339(&ts) {
393 let age = chrono::Utc::now()
394 .signed_duration_since(imported_at)
395 .num_seconds()
396 .unsigned_abs();
397 if age > self.config.projection.import_staleness_threshold_secs {
398 warnings.push(QueryWarning::ProjectionImportStale {
399 scope: scope_key.clone(),
400 last_import_at: Some(ts),
401 });
402 }
403 }
404 }
405 }
406
407 let classification = force_explicit_temporal_classification(classify::classify(query));
408 let plan = route::plan(
409 query,
410 &classification.mode,
411 scope,
412 self.config.query.default_limit,
413 );
414
415 let explicit_temporal = Some((valid_at.to_string(), recorded_at_or_before.to_string()));
416 let (leg_results, leg_timings, derived_candidate_receipts) = self
417 .execute_plan(
418 &plan,
419 &scope_key,
420 has_projection_imports,
421 &mut warnings,
422 explicit_temporal,
423 )
424 .await?;
425
426 let policy = MergePolicy {
427 limit: self.config.query.default_limit,
428 ..Default::default()
429 };
430 let merged = merge::merge(leg_results, &policy);
431
432 let total_duration = start.elapsed();
433 let has_downgrade = warnings
434 .iter()
435 .any(|w| matches!(w, QueryWarning::TemporalDowngradedToHybrid { .. }));
436 let temporal_mode = if has_downgrade { "downgraded" } else { "exact" };
437 let mut trace = QueryTrace::from_pipeline(
438 trace_ctx,
439 scope_key,
440 classification,
441 plan,
442 leg_timings,
443 total_duration,
444 &merged,
445 warnings,
446 );
447 trace.valid_as_of = Some(valid_at.to_string());
448 trace.recorded_as_of = Some(recorded_at_or_before.to_string());
449 trace.temporal_mode = Some(temporal_mode.to_string());
450 trace.derived_candidate_receipts = derived_candidate_receipts;
451
452 let results = merged.results.into_iter().map(|m| m.result).collect();
453 Ok((results, trace))
454 }
455
456 pub async fn query_evidence_refs_for_claim(
461 &self,
462 claim_id: &str,
463 claim_version_id: Option<&str>,
464 scope: Option<&Scope>,
465 limit: usize,
466 ) -> Result<Vec<ProjectionEvidenceRef>, RuntimeError> {
467 let scope = scope.unwrap_or(&self.config.default_scope);
468 self.adapter
469 .query_evidence_refs_for_claim(claim_id, claim_version_id, scope, None, limit)
470 .await
471 }
472
473 pub async fn query_evidence_refs_for_claim_as_of(
475 &self,
476 claim_id: &str,
477 claim_version_id: Option<&str>,
478 scope: Option<&Scope>,
479 recorded_at_or_before: &str,
480 limit: usize,
481 ) -> Result<Vec<ProjectionEvidenceRef>, RuntimeError> {
482 let scope = scope.unwrap_or(&self.config.default_scope);
483 self.adapter
484 .query_evidence_refs_for_claim(
485 claim_id,
486 claim_version_id,
487 scope,
488 Some(recorded_at_or_before),
489 limit,
490 )
491 .await
492 }
493
494 pub async fn query_verification_summary_for_claim(
499 &self,
500 claim_id: &str,
501 claim_version_id: Option<&str>,
502 scope: Option<&Scope>,
503 ) -> Result<Option<ProjectedVerificationSummary>, RuntimeError> {
504 let scope = scope.unwrap_or(&self.config.default_scope);
505 let rows = self
506 .adapter
507 .query_claim_versions_for_claim(claim_id, claim_version_id, scope, None, 8)
508 .await?;
509 Ok(rows.first().map(projected_verification_summary_from_claim))
510 }
511
512 pub async fn query_verification_summary_for_claim_as_of(
514 &self,
515 claim_id: &str,
516 claim_version_id: Option<&str>,
517 scope: Option<&Scope>,
518 recorded_at_or_before: &str,
519 ) -> Result<Option<ProjectedVerificationSummary>, RuntimeError> {
520 let scope = scope.unwrap_or(&self.config.default_scope);
521 let rows = self
522 .adapter
523 .query_claim_versions_for_claim(
524 claim_id,
525 claim_version_id,
526 scope,
527 Some(recorded_at_or_before),
528 8,
529 )
530 .await?;
531 Ok(rows.first().map(projected_verification_summary_from_claim))
532 }
533
534 pub fn classify(&self, query: &str) -> ClassifyResult {
536 classify::classify(query)
537 }
538
539 pub fn plan(&self, query: &str, scope: Option<&Scope>) -> RoutePlan {
541 let scope = scope.unwrap_or(&self.config.default_scope);
542 let classification = classify::classify(query);
543 route::plan(
544 query,
545 &classification.mode,
546 scope,
547 self.config.query.default_limit,
548 )
549 }
550
551 pub fn entity_registry(&self) -> &EntityRegistry {
555 &self.entity_registry
556 }
557
558 pub fn refresh_entity_cache(&mut self) -> EntityCacheHandle<'_> {
573 EntityCacheHandle {
574 registry: &mut self.entity_registry,
575 }
576 }
577
578 pub fn projection_health(&self, id: &ProjectionId) -> ProjectionHealth {
585 self.projection_tracker.health(id)
586 }
587
588 pub fn projection_status(
590 &self,
591 kind: Option<&ProjectionKind>,
592 scope: Option<&ScopeKey>,
593 ) -> Vec<&crate::projection::lifecycle::ProjectionMeta> {
594 self.projection_tracker.query_status(kind, scope)
595 }
596
597 pub fn record_projection_build(
599 &mut self,
600 id: ProjectionId,
601 source_count: usize,
602 build_duration_ms: u64,
603 version: Option<ProjectionVersion>,
604 ) {
605 self.projection_tracker
606 .record_build(id, source_count, build_duration_ms, version);
607 }
608
609 pub fn record_projection_failure(&mut self, id: ProjectionId, error: String) {
611 self.projection_tracker.record_failure(id, error);
612 }
613
614 pub fn invalidate_projections(&mut self, event: &InvalidationEvent) -> ProjectionActionResult {
616 self.projection_tracker.invalidate(event)
617 }
618
619 pub fn invalidate_projections_by_kind(
621 &mut self,
622 kind: &ProjectionKind,
623 scope: &ScopeKey,
624 cause: StaleCause,
625 ) -> ProjectionActionResult {
626 self.projection_tracker
627 .invalidate_by_kind_and_scope(kind, scope, cause)
628 }
629
630 pub fn invalidate_scope(
632 &mut self,
633 scope: &ScopeKey,
634 cause: StaleCause,
635 ) -> ProjectionActionResult {
636 self.projection_tracker.invalidate_scope(scope, cause)
637 }
638
639 pub fn clear_projection_scope(&mut self, scope: &ScopeKey) -> ProjectionActionResult {
641 self.projection_tracker.clear_scope(scope)
642 }
643
644 pub fn projection_tracker(&self) -> &ProjectionTracker {
652 &self.projection_tracker
653 }
654
655 pub fn adapter(&self) -> &SemanticMemoryAdapter {
659 &self.adapter
660 }
661
662 pub fn config(&self) -> &RuntimeConfig {
664 &self.config
665 }
666
667 async fn execute_plan(
671 &self,
672 plan: &RoutePlan,
673 scope_key: &ScopeKey,
674 has_projection_imports: bool,
675 warnings: &mut Vec<QueryWarning>,
676 explicit_temporal: Option<(String, String)>,
677 ) -> Result<
678 (
679 Vec<Vec<LegResult>>,
680 Vec<u64>,
681 Vec<RuntimeDerivedCandidateTraceV1>,
682 ),
683 RuntimeError,
684 > {
685 let mut all_legs = Vec::with_capacity(plan.legs.len());
686 let mut timings = Vec::with_capacity(plan.legs.len());
687 let mut derived_candidate_receipts = Vec::new();
688 let scope_requires_pushdown = scope_has_extra_dimensions(&plan.scope);
689 for (i, leg) in plan.legs.iter().enumerate() {
690 let leg_start = Instant::now();
691
692 let results = match &leg.strategy {
693 RetrievalStrategy::HybridSearch => {
694 if has_projection_imports {
695 if let Some((valid_at, recorded_at_or_before)) = explicit_temporal.as_ref()
696 {
697 self.adapter
698 .search_projection_temporal_with_recorded_at(
699 &plan.query,
700 &plan.scope,
701 valid_at,
702 recorded_at_or_before,
703 leg.limit,
704 )
705 .await?
706 } else {
707 let projection_results = self
708 .adapter
709 .search_projection_text(&plan.query, &plan.scope, leg.limit)
710 .await?;
711 if scope_requires_pushdown && projection_results.len() >= leg.limit {
716 projection_results
717 } else {
718 let fallback = self
719 .semantic_search_leg(
720 &plan.query,
721 &plan.scope,
722 leg.limit,
723 &mut derived_candidate_receipts,
724 )
725 .await?;
726 merge_leg_results(projection_results, fallback, leg.limit)
727 }
728 }
729 } else {
730 if explicit_temporal.is_some() {
731 self.handle_temporal_fallback(
732 EXPLICIT_TEMPORAL_LABEL,
733 &plan.scope,
734 warnings,
735 )?;
736 }
737 self.semantic_search_leg(
738 &plan.query,
739 &plan.scope,
740 leg.limit,
741 &mut derived_candidate_receipts,
742 )
743 .await?
744 }
745 }
746 RetrievalStrategy::EntitySearch { mention } => {
747 let resolve = self.entity_registry.resolve(mention, scope_key);
748 if resolve.quality == crate::entity::registry::MatchQuality::ScopedFallback {
749 warnings.push(QueryWarning::EntityScopeFallback {
750 mention: mention.clone(),
751 queried_scope: scope_key.clone(),
752 });
753 }
754 let mut candidate_ids = Vec::new();
755 if let Some(entity) = resolve.entity.as_ref() {
756 candidate_ids.push(entity.id.clone());
757 }
758
759 if has_projection_imports {
760 let alias_limit = leg.limit.clamp(4, 16);
761 let alias_candidates = if let Some((valid_at, recorded_at_or_before)) =
762 explicit_temporal.as_ref()
763 {
764 self.adapter
765 .query_alias_candidates_with_recorded_at(
766 mention,
767 &plan.scope,
768 valid_at,
769 recorded_at_or_before,
770 alias_limit,
771 )
772 .await?
773 } else {
774 self.adapter
775 .query_alias_candidates(mention, &plan.scope, alias_limit)
776 .await?
777 };
778 for alias in alias_candidates {
779 if !candidate_ids
780 .iter()
781 .any(|id| id == &alias.canonical_entity_id)
782 {
783 candidate_ids.push(alias.canonical_entity_id);
784 }
785 }
786
787 if !candidate_ids.is_empty() {
788 let projection_results =
789 if let Some((valid_at, recorded_at_or_before)) =
790 explicit_temporal.as_ref()
791 {
792 self.adapter
793 .search_projection_by_entity_ids_with_recorded_at(
794 &plan.scope,
795 &candidate_ids,
796 valid_at,
797 recorded_at_or_before,
798 leg.limit,
799 )
800 .await?
801 } else {
802 self.adapter
803 .search_projection_by_entity_ids(
804 &plan.scope,
805 &candidate_ids,
806 leg.limit,
807 )
808 .await?
809 };
810 if scope_requires_pushdown || explicit_temporal.is_some() {
811 projection_results
812 } else {
813 let search_term = resolve
814 .entity
815 .as_ref()
816 .map(|e| e.canonical_name.as_str())
817 .unwrap_or(mention);
818 let fallback = self
819 .semantic_search_leg(
820 search_term,
821 &plan.scope,
822 leg.limit,
823 &mut derived_candidate_receipts,
824 )
825 .await?;
826 merge_leg_results(projection_results, fallback, leg.limit)
827 }
828 } else {
829 if explicit_temporal.is_some() {
830 self.handle_temporal_fallback(
831 EXPLICIT_TEMPORAL_LABEL,
832 &plan.scope,
833 warnings,
834 )?;
835 }
836 let search_term = resolve
837 .entity
838 .as_ref()
839 .map(|e| e.canonical_name.as_str())
840 .unwrap_or(mention);
841 self.semantic_search_leg(
842 search_term,
843 &plan.scope,
844 leg.limit,
845 &mut derived_candidate_receipts,
846 )
847 .await?
848 }
849 } else {
850 if explicit_temporal.is_some() {
851 self.handle_temporal_fallback(
852 EXPLICIT_TEMPORAL_LABEL,
853 &plan.scope,
854 warnings,
855 )?;
856 }
857 let search_term = resolve
858 .entity
859 .as_ref()
860 .map(|e| e.canonical_name.as_str())
861 .unwrap_or(mention);
862 self.semantic_search_leg(
863 search_term,
864 &plan.scope,
865 leg.limit,
866 &mut derived_candidate_receipts,
867 )
868 .await?
869 }
870 }
871 RetrievalStrategy::TemporalSearch { temporal_expr } => {
872 if has_projection_imports {
873 if let Some((valid_at, recorded_at_or_before)) = explicit_temporal.as_ref()
874 {
875 self.adapter
876 .search_projection_temporal_with_recorded_at(
877 &plan.query,
878 &plan.scope,
879 valid_at,
880 recorded_at_or_before,
881 leg.limit,
882 )
883 .await?
884 } else if let Some(valid_at) = resolve_temporal_as_of(temporal_expr) {
885 self.adapter
886 .search_projection_temporal(
887 &plan.query,
888 &plan.scope,
889 &valid_at,
890 leg.limit,
891 )
892 .await?
893 } else {
894 self.handle_temporal_fallback(temporal_expr, &plan.scope, warnings)?;
895 self.semantic_search_leg(
896 &plan.query,
897 &plan.scope,
898 leg.limit,
899 &mut derived_candidate_receipts,
900 )
901 .await?
902 }
903 } else {
904 self.handle_temporal_fallback(temporal_expr, &plan.scope, warnings)?;
905 self.semantic_search_leg(
906 &plan.query,
907 &plan.scope,
908 leg.limit,
909 &mut derived_candidate_receipts,
910 )
911 .await?
912 }
913 }
914 };
915
916 let leg_results: Vec<LegResult> = results
917 .into_iter()
918 .map(|r| LegResult {
919 leg_index: i,
920 result: r,
921 })
922 .collect();
923
924 timings.push(leg_start.elapsed().as_millis() as u64);
925 all_legs.push(leg_results);
926 }
927
928 Ok((all_legs, timings, derived_candidate_receipts))
929 }
930
931 async fn semantic_search_leg(
932 &self,
933 query: &str,
934 scope: &Scope,
935 limit: usize,
936 receipts: &mut Vec<RuntimeDerivedCandidateTraceV1>,
937 ) -> Result<Vec<SearchResult>, RuntimeError> {
938 let (results, receipt) = self
939 .adapter
940 .search_with_receipt(query, scope, Some(limit), None)
941 .await?;
942 if let Some(receipt) = receipt {
943 if matches!(
944 self.config.query.candidate_backend_hint,
945 RuntimeCandidateBackendHint::ProveKvPoolCandidate
946 ) || receipt.codec_family.as_deref() == Some("provekv_pool")
947 || receipt
948 .candidate_backend
949 .contains("provekv_pool_candidate_then_exact_f32")
950 {
951 receipts.push(receipt);
952 }
953 }
954 Ok(results)
955 }
956
957 fn handle_temporal_fallback(
958 &self,
959 temporal_expr: &str,
960 scope: &Scope,
961 warnings: &mut Vec<QueryWarning>,
962 ) -> Result<(), RuntimeError> {
963 if self.config.strict_temporal {
964 return Err(RuntimeError::TemporalNotSupported {
965 temporal_expr: temporal_expr.to_string(),
966 });
967 }
968
969 push_temporal_warning_once(warnings, temporal_expr);
970 tracing::warn!(
971 temporal_expr = %temporal_expr,
972 "temporal execution unsupported for this route, degrading to hybrid"
973 );
974 let _ = scope;
975 Ok(())
976 }
977}
978
979fn scope_has_extra_dimensions(scope: &Scope) -> bool {
980 scope.domain.is_some() || scope.workspace_id.is_some() || scope.repo_id.is_some()
981}
982
983fn push_temporal_warning_once(warnings: &mut Vec<QueryWarning>, temporal_expr: &str) {
984 let already_present = warnings.iter().any(|warning| {
985 matches!(
986 warning,
987 QueryWarning::TemporalDowngradedToHybrid { temporal_expr: seen }
988 if seen == temporal_expr
989 )
990 });
991 if !already_present {
992 warnings.push(QueryWarning::TemporalDowngradedToHybrid {
993 temporal_expr: temporal_expr.to_string(),
994 });
995 }
996}
997
998fn projected_verification_summary_from_claim(
999 claim: &ProjectionClaimVersion,
1000) -> ProjectedVerificationSummary {
1001 let parsed_metadata = claim
1002 .metadata
1003 .as_ref()
1004 .and_then(|metadata| metadata.get("verification_summary").cloned())
1005 .and_then(|summary| serde_json::from_value::<MetadataVerificationSummary>(summary).ok());
1006 let top_level_promotion_state = claim
1007 .metadata
1008 .as_ref()
1009 .and_then(|metadata| metadata.get("promotion_state").cloned())
1010 .and_then(|state| serde_json::from_value::<ProjectedPromotionState>(state).ok());
1011
1012 let lifecycle_state = parsed_metadata
1013 .as_ref()
1014 .map(|summary| summary.lifecycle_state.clone())
1015 .unwrap_or_else(|| fallback_lifecycle_state(claim));
1016 let promotion_state = parsed_metadata
1017 .as_ref()
1018 .map(|summary| summary.promotion_state.clone())
1019 .or(top_level_promotion_state)
1020 .unwrap_or(ProjectedPromotionState::NotPromoted);
1021
1022 ProjectedVerificationSummary {
1023 claim_id: claim.claim_id.as_str().to_string(),
1024 claim_version_id: claim.claim_version_id.as_str().to_string(),
1025 lifecycle_state,
1026 promotion_state,
1027 claim_state: claim.claim_state.clone(),
1028 freshness: claim.freshness.clone(),
1029 contradiction_status: normalize_contradiction_status(&claim.contradiction_status),
1030 completed_trial_count: parsed_metadata
1031 .as_ref()
1032 .map(|summary| summary.completed_trial_count)
1033 .unwrap_or(0),
1034 passed_refutation_count: parsed_metadata
1035 .as_ref()
1036 .map(|summary| summary.passed_refutation_count)
1037 .unwrap_or(0),
1038 failed_refutation_count: parsed_metadata
1039 .as_ref()
1040 .map(|summary| summary.failed_refutation_count)
1041 .unwrap_or(0),
1042 comparability_snapshot_version: parsed_metadata
1043 .as_ref()
1044 .and_then(|summary| summary.comparability_snapshot_version.clone())
1045 .or_else(|| {
1046 claim.metadata.as_ref().and_then(|metadata| {
1047 metadata
1048 .get("comparability_snapshot_version")
1049 .and_then(serde_json::Value::as_str)
1050 .map(str::to_string)
1051 })
1052 }),
1053 notes: parsed_metadata
1054 .as_ref()
1055 .map(|summary| summary.notes.clone())
1056 .unwrap_or_default(),
1057 source_envelope_id: claim.source_envelope_id.as_str().to_string(),
1058 recorded_at: claim.recorded_at.clone(),
1059 }
1060}
1061
1062fn fallback_lifecycle_state(claim: &ProjectionClaimVersion) -> ProjectedVerificationLifecycle {
1063 if claim.claim_state == "superseded" || claim.freshness == "superseded" {
1064 ProjectedVerificationLifecycle::Superseded
1065 } else if claim.claim_state == "disputed"
1066 || !contradiction_status_is_none(&claim.contradiction_status)
1067 {
1068 ProjectedVerificationLifecycle::Contradicted
1069 } else {
1070 ProjectedVerificationLifecycle::Unverified
1071 }
1072}
1073
1074fn contradiction_status_is_none(status: &str) -> bool {
1075 matches!(
1076 serde_json::from_str::<serde_json::Value>(status),
1077 Ok(serde_json::Value::String(value)) if value == "none"
1078 ) || status == "none"
1079}
1080
1081fn normalize_contradiction_status(status: &str) -> String {
1082 match serde_json::from_str::<serde_json::Value>(status) {
1083 Ok(serde_json::Value::String(value)) => value,
1084 Ok(serde_json::Value::Object(obj)) => {
1085 if let Some(description) = obj
1086 .get("possible_contradiction")
1087 .and_then(|value| value.get("description"))
1088 .and_then(serde_json::Value::as_str)
1089 {
1090 return format!("possible_contradiction:{description}");
1091 }
1092 if let Some(contradicted_by) = obj
1093 .get("confirmed")
1094 .and_then(|value| value.get("contradicted_by"))
1095 .and_then(serde_json::Value::as_str)
1096 {
1097 return format!("confirmed:{contradicted_by}");
1098 }
1099 if let Some(resolution) = obj
1100 .get("resolved")
1101 .and_then(|value| value.get("resolution"))
1102 .and_then(serde_json::Value::as_str)
1103 {
1104 return format!("resolved:{resolution}");
1105 }
1106 status.to_string()
1107 }
1108 _ => status.to_string(),
1109 }
1110}
1111
1112fn merge_leg_results(
1113 mut primary: Vec<SearchResult>,
1114 fallback: Vec<SearchResult>,
1115 limit: usize,
1116) -> Vec<SearchResult> {
1117 primary.extend(fallback);
1118 primary.sort_by(|left, right| {
1119 right
1120 .score
1121 .partial_cmp(&left.score)
1122 .unwrap_or(std::cmp::Ordering::Equal)
1123 });
1124 primary.truncate(limit);
1125 primary
1126}
1127
1128fn resolve_temporal_as_of(temporal_expr: &str) -> Option<String> {
1129 use chrono::{Datelike, Duration, NaiveDate, Utc};
1130
1131 let now = Utc::now();
1132 let resolved = match temporal_expr.to_lowercase().as_str() {
1133 "today" | "recent" | "recently" | "latest" => now,
1134 "yesterday" => now - Duration::days(1),
1135 "last week" => now - Duration::weeks(1),
1136 "last month" => now - Duration::days(30),
1137 "last year" => now - Duration::days(365),
1138 "this week" => {
1139 let days_from_monday = now.weekday().num_days_from_monday() as i64;
1140 let date = now.date_naive() - Duration::days(days_from_monday);
1141 chrono::DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0)?, Utc)
1142 }
1143 "this month" => {
1144 let date = NaiveDate::from_ymd_opt(now.year(), now.month(), 1)?;
1145 chrono::DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0)?, Utc)
1146 }
1147 _ => return None,
1148 };
1149 Some(resolved.to_rfc3339())
1150}
1151
1152pub struct EntityCacheHandle<'a> {
1158 registry: &'a mut EntityRegistry,
1159}
1160
1161impl<'a> EntityCacheHandle<'a> {
1162 pub fn from_registry_mut(registry: &'a mut EntityRegistry) -> Self {
1168 Self { registry }
1169 }
1170
1171 pub fn load_from_upstream(&mut self, entity: Entity) -> Result<(), RuntimeError> {
1177 self.registry.register(entity)
1178 }
1179
1180 pub fn clear_scope(&mut self, scope: &ScopeKey) {
1182 self.registry.clear_scope(scope)
1183 }
1184
1185 pub fn clear_all(&mut self) {
1187 self.registry.clear_all()
1188 }
1189
1190 pub fn len(&self) -> usize {
1192 self.registry.len()
1193 }
1194
1195 pub fn is_empty(&self) -> bool {
1197 self.registry.is_empty()
1198 }
1199}
1200
1201impl std::fmt::Debug for KnowledgeRuntime {
1202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1203 f.debug_struct("KnowledgeRuntime")
1204 .field("config", &self.config)
1205 .field("entity_count", &self.entity_registry.len())
1206 .field("projection_count", &self.projection_tracker.list().len())
1207 .finish()
1208 }
1209}