Skip to main content

knowledge_runtime/runtime/
core.rs

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/// Lifecycle state projected from imported verification truth.
54#[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/// Promotion state projected from imported verification truth.
64#[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/// Runtime-facing typed read of verification and promotion state.
81#[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
112/// The main runtime handle.
113///
114/// `KnowledgeRuntime` orchestrates query classification, route planning,
115/// retrieval execution, and result merging. It owns derived projections
116/// (entity registry, projection tracker) but NEVER owns source truth —
117/// all facts, episodes, and documents live in `semantic-memory`.
118///
119/// ## What is implemented
120///
121/// - Rule-based query classification (semantic, entity, temporal, mixed)
122/// - Route planning from classification
123/// - Projection-backed retrieval for imported claims, relations, and episodes
124///   with hybrid fallback only when the projection substrate is unavailable
125/// - Entity search with scope-aware registry resolution and bounded imported
126///   alias candidate expansion
127/// - Result merge with duplicate fusion and multi-leg provenance
128/// - Projection health/status tracking with invalidation by scope/kind
129/// - Query traces with degradation warnings
130/// - Dedicated explain/audit entrypoints for imported evidence refs
131/// - **Temporal search execution on supported projection routes**: Temporal legs
132///   resolve supported expressions to concrete as-of timestamps and query imported
133///   versions directly. Explicit bitemporal parameters in `query_temporal()` are
134///   now also applied on projection-backed hybrid and entity routes. Unsupported
135///   expressions or missing projection substrate degrade explicitly when strict
136///   mode is off.
137/// - **Route-aware scope enforcement**: Full scope is enforced on projection-backed
138///   routes. `ScopePartiallyEnforced` is emitted only when execution must fall back
139///   to namespace-only hybrid retrieval.
140/// - **Non-authoritative entity cache**: The entity registry is accessible via
141///   `refresh_entity_cache()` which returns a fenced `EntityCacheHandle` that
142///   only exposes cache-refresh operations. Direct mutable access to the
143///   registry has been removed to enforce non-authoritative semantics.
144///
145/// ## What is NOT implemented
146///
147/// - **Range temporal expressions**: `before`, `after`, `since`, `between`,
148///   and similar range-oriented phrases still degrade explicitly because the
149///   runtime currently supports only concrete as-of timestamps on projection
150///   routes.
151/// - **Projection persistence**: Not implemented in this crate today. The
152///   `persist` config field is retained for serde backward compatibility but
153///   rejected at validation time (`InvalidConfig` error). Projections are
154///   derived, non-authoritative, and rebuildable from upstream state.
155/// - **Projection rebuild execution**: The tracker records build/failure events
156///   but does not trigger actual rebuilds. Callers must drive rebuilds.
157///   **The projection tracker is observability-only, not an orchestration
158///   primitive.** It records state transitions (build, failure, invalidation)
159///   for inspection and diagnostics. It does NOT schedule, execute, or retry
160///   projection rebuilds. Any rebuild logic must live in an external
161///   orchestrator that reads tracker state and calls the appropriate APIs.
162/// - **LLM-based classification**: Classifier is rule-based heuristic.
163/// - **Advanced explain/audit cross-system dereference**: The runtime does not
164///   dereference raw evidence handles.
165pub struct KnowledgeRuntime {
166    adapter: SemanticMemoryAdapter,
167    config: RuntimeConfig,
168    entity_registry: EntityRegistry,
169    projection_tracker: ProjectionTracker,
170}
171
172impl KnowledgeRuntime {
173    /// Create a new runtime from a config and a semantic-memory adapter.
174    ///
175    /// Returns `InvalidConfig` if the config contains invalid values,
176    /// including `projection.persist = true` (persistence is not implemented).
177    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    // ── Query pipeline ──────────────────────────────────────────
204
205    /// Execute a query through the full pipeline: classify -> plan -> execute -> merge.
206    ///
207    /// Returns merged results and a query trace for observability.
208    /// The trace includes any degradation warnings (e.g. temporal downgrade).
209    ///
210    /// Generates a fresh `TraceCtx` internally. To supply a caller-owned trace
211    /// context (for cross-crate correlation), use [`query_with_trace()`](Self::query_with_trace).
212    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    /// Execute a query with an optional caller-supplied `TraceCtx`.
221    ///
222    /// If `trace_ctx` is `None`, a fresh trace context is generated.
223    /// This is the primary query entry point for callers that need
224    /// cross-crate trace correlation (e.g., bridge -> runtime -> memory).
225    ///
226    /// ## Scope enforcement
227    ///
228    /// Scope handling is route-aware. Projection-backed routes enforce the full
229    /// scope directly against imported rows. Namespace-only hybrid fallback emits
230    /// `ScopePartiallyEnforced` only when the runtime lacks a projection-backed
231    /// route for the requested leg and strict scope is disabled.
232    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        // Phase 1: Classify
276        let classification = classify::classify(query);
277
278        // Phase 2: Plan
279        let plan = route::plan(
280            query,
281            &classification.mode,
282            scope,
283            self.config.query.default_limit,
284        );
285
286        // Phase 3: Execute legs
287        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        // Phase 4: Merge
298        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        // Phase 5: Build trace
307        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    /// Execute a query and attach the latest non-authoritative kernel
324    /// explanation available for the queried scope.
325    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    /// Execute a temporal query with explicit bitemporal semantics.
340    ///
341    /// This is the public equivalent for:
342    /// `as_of(valid_t, recorded_t_or_before)`.
343    ///
344    /// `valid_at` filters by projection-valid time, while
345    /// `recorded_at_or_before` filters by importer transaction time.
346    /// This method keeps the same warning behavior as the normal pipeline for
347    /// scope and temporal fallback on supported routes.
348    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    /// Execute an explicit-temporal query with an explicit trace context.
360    ///
361    /// See [`query_temporal`](Self::query_temporal) for semantics.
362    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    /// Query evidence references for a claim via the explicit explain/audit path.
457    ///
458    /// This returns imported evidence handles as opaque rows and does not alter
459    /// normal retrieval behavior.
460    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    /// Query evidence references for a claim as of a transaction-time cutoff.
474    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    /// Query the projected verification summary for a claim.
495    ///
496    /// This reads imported claim rows only. It does not reconstruct truth from
497    /// raw evidence dereference, and it does not mutate runtime state.
498    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    /// Query the projected verification summary for a claim as of a recorded-time cutoff.
513    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    /// Classify a query without executing it.
535    pub fn classify(&self, query: &str) -> ClassifyResult {
536        classify::classify(query)
537    }
538
539    /// Plan a query without executing it.
540    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    // ── Entity registry access ──────────────────────────────────
552
553    /// Access the entity registry for read-only lookup.
554    pub fn entity_registry(&self) -> &EntityRegistry {
555        &self.entity_registry
556    }
557
558    /// Get a cache-refresh handle for loading entities from upstream canonical state.
559    ///
560    /// ## Authority boundary
561    ///
562    /// The entity registry is a **derived, non-authoritative cache**. This handle
563    /// only exposes cache-refresh operations — not authority-like mutation.
564    ///
565    /// Use this for:
566    /// - Loading entities from upstream memory canonical state
567    /// - Rebuilding the cache after invalidation
568    /// - Clearing scope or full cache for rebuild
569    /// - Test setup
570    ///
571    /// Runtime MUST NOT become an authoritative source of identity state.
572    pub fn refresh_entity_cache(&mut self) -> EntityCacheHandle<'_> {
573        EntityCacheHandle {
574            registry: &mut self.entity_registry,
575        }
576    }
577
578    // ── Projection maintenance ──────────────────────────────────
579
580    /// Get the health of a specific projection.
581    ///
582    /// **Observability only.** This reads tracker state; the tracker does not
583    /// execute rebuilds or schedule any work based on health status.
584    pub fn projection_health(&self, id: &ProjectionId) -> ProjectionHealth {
585        self.projection_tracker.health(id)
586    }
587
588    /// Query projection status by optional kind and scope filters.
589    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    /// Record that a projection was successfully built.
598    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    /// Record that a projection build failed.
610    pub fn record_projection_failure(&mut self, id: ProjectionId, error: String) {
611        self.projection_tracker.record_failure(id, error);
612    }
613
614    /// Invalidate specific projections.
615    pub fn invalidate_projections(&mut self, event: &InvalidationEvent) -> ProjectionActionResult {
616        self.projection_tracker.invalidate(event)
617    }
618
619    /// Invalidate all projections of a given kind within a scope.
620    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    /// Invalidate all projections within a scope.
631    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    /// Clear all projections within a scope.
640    pub fn clear_projection_scope(&mut self, scope: &ScopeKey) -> ProjectionActionResult {
641        self.projection_tracker.clear_scope(scope)
642    }
643
644    /// Access the projection tracker directly (read-only).
645    ///
646    /// **Observability only, not orchestration.** The tracker records
647    /// projection lifecycle events (build, invalidation, failure) but
648    /// does not trigger, schedule, or retry any work. External callers
649    /// must drive rebuild execution and report outcomes via
650    /// `record_projection_build()` / `record_projection_failure()`.
651    pub fn projection_tracker(&self) -> &ProjectionTracker {
652        &self.projection_tracker
653    }
654
655    // ── Adapter / config access ─────────────────────────────────
656
657    /// Access the underlying semantic-memory adapter.
658    pub fn adapter(&self) -> &SemanticMemoryAdapter {
659        &self.adapter
660    }
661
662    /// Access the runtime config.
663    pub fn config(&self) -> &RuntimeConfig {
664        &self.config
665    }
666
667    // ── Internal ────────────────────────────────────────────────
668
669    /// Execute all legs of a route plan, returning per-leg results and timings.
670    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                            // LIB-CRIT-001: Always merge with hybrid (FTS5 + HNSW + RRF)
712                            // unless scope requires pushdown and projection alone is sufficient.
713                            // Projection only has substring matching — hybrid search provides
714                            // semantic retrieval via embeddings.
715                            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
1152/// Handle for refreshing the entity cache from upstream canonical state.
1153///
1154/// This is intentionally NOT the full [`EntityRegistry`] API — it only exposes
1155/// cache-refresh operations to enforce non-authoritative semantics. The entity
1156/// registry is a derived projection that can be rebuilt without data loss.
1157pub struct EntityCacheHandle<'a> {
1158    registry: &'a mut EntityRegistry,
1159}
1160
1161impl<'a> EntityCacheHandle<'a> {
1162    /// Create a cache handle from a mutable reference to an entity registry.
1163    ///
1164    /// This is useful for testing or when the caller already has a mutable
1165    /// reference to an `EntityRegistry` (e.g., outside the runtime context).
1166    /// In normal runtime usage, prefer [`KnowledgeRuntime::refresh_entity_cache()`].
1167    pub fn from_registry_mut(registry: &'a mut EntityRegistry) -> Self {
1168        Self { registry }
1169    }
1170
1171    /// Load an entity from upstream canonical state into the cache.
1172    ///
1173    /// This registers (or updates) an entity in the derived cache.
1174    /// The entity data should originate from an upstream canonical source
1175    /// (e.g., `semantic-memory` entity_aliases table).
1176    pub fn load_from_upstream(&mut self, entity: Entity) -> Result<(), RuntimeError> {
1177        self.registry.register(entity)
1178    }
1179
1180    /// Clear all cached entities in a scope (for targeted rebuild).
1181    pub fn clear_scope(&mut self, scope: &ScopeKey) {
1182        self.registry.clear_scope(scope)
1183    }
1184
1185    /// Clear all cached entities (for full rebuild).
1186    pub fn clear_all(&mut self) {
1187        self.registry.clear_all()
1188    }
1189
1190    /// Number of entities currently in the cache.
1191    pub fn len(&self) -> usize {
1192        self.registry.len()
1193    }
1194
1195    /// Whether the cache is empty.
1196    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}