Skip to main content

semantic_memory/
evidence_gap.rs

1//! Bounded evidence-gap retrieval and state-aware reranking.
2//!
3//! This is an interpretation layer over the existing search, graph, authority, and witnessed
4//! retrieval paths. It does not create a second evidence store and it never treats an empty,
5//! stale, conflicted, or budget-limited result as a confident negative.
6
7use crate::authority_contracts::{RetrievalWitnessV1, StageOutcomeV1};
8use crate::origin_authority::{
9    AuthorityScopeV1, GovernedAccessRequestV1, OriginAuthorityRecordV1, OriginRiskV1,
10};
11use crate::state_epistemics::{
12    answer_policy_for, resolve_dependency_states, AnswerPolicyDecision, DependencyResolutionV1,
13    DependencyState, PremiseStatus, StateResolutionMode, StateResolutionReceiptV1,
14};
15use crate::types::{GraphEdgeType, SearchContext, SearchResult, SearchSource, SearchSourceType};
16use crate::{MemoryError, MemoryStore, StateView};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeSet;
19
20pub const EVIDENCE_GAP_V1: &str = "evidence_gap_v1";
21pub const EVIDENCE_PACKET_V1: &str = "evidence_packet_v1";
22pub const EVIDENCE_ROUTE_RECEIPT_V1: &str = "evidence_route_receipt_v1";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum EvidenceTerminalOutcomeV1 {
27    Sufficient,
28    EvidenceInsufficient,
29    BudgetExceeded,
30    Conflict,
31    InvalidLineage,
32}
33
34/// Compatibility spelling for callers that name the terminal result after the controller.
35pub type EvidenceGapOutcomeV1 = EvidenceTerminalOutcomeV1;
36pub type EvidenceTerminalOutcome = EvidenceTerminalOutcomeV1;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum EvidenceGapReasonV1 {
41    MissingRequiredEvidence,
42    MissingStateSupport,
43    MissingOriginAuthority,
44    NoCandidates,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct EvidenceGapV1 {
49    pub schema_version: String,
50    pub gap_id: String,
51    pub reason: EvidenceGapReasonV1,
52    pub requirement: String,
53    pub missing_items: Vec<String>,
54    pub diagnosed_by: Vec<String>,
55    pub follow_up_queries: Vec<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum EvidenceRetrievalRouteV1 {
61    InitialHybrid,
62    Lexical,
63    Vector,
64    Graph,
65    Episodic,
66    DirectId,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct EvidenceRouteReceiptV1 {
71    pub schema_version: String,
72    pub route: EvidenceRetrievalRouteV1,
73    pub query: Option<String>,
74    pub stage_outcome: StageOutcomeV1,
75    pub result_ids: Vec<String>,
76    pub order_changed: bool,
77    pub budget_unit: usize,
78    pub note: Option<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct StateRerankWeightsV1 {
83    pub state_relevance: f64,
84    pub support: f64,
85    pub origin_authority: f64,
86}
87
88impl Default for StateRerankWeightsV1 {
89    fn default() -> Self {
90        Self {
91            // State relevance is intentionally dominant: stale lexical matches must not win
92            // merely because they have a higher BM25/vector fusion score.
93            state_relevance: 2.0,
94            support: 0.5,
95            origin_authority: 0.25,
96        }
97    }
98}
99
100#[derive(Debug, Clone)]
101pub struct StateRerankCandidateV1 {
102    pub result: SearchResult,
103    pub state_status: PremiseStatus,
104    pub state_relevance: f64,
105    pub support_score: f64,
106    pub origin_authority_score: f64,
107    pub original_score: f64,
108    pub reranked_score: f64,
109    pub origin_label_digest: Option<String>,
110}
111
112impl StateRerankCandidateV1 {
113    pub fn from_result(
114        result: SearchResult,
115        state_status: PremiseStatus,
116        state_relevance: f64,
117    ) -> Self {
118        let support_score = match (result.bm25_rank.is_some(), result.vector_rank.is_some()) {
119            (true, true) => 1.0,
120            (true, false) | (false, true) => 0.7,
121            (false, false) => 0.25,
122        };
123        let original_score = result.score;
124        let state_relevance = state_relevance.min(state_relevance_for_status(state_status));
125        Self {
126            result,
127            state_status,
128            state_relevance,
129            support_score,
130            origin_authority_score: 0.5,
131            original_score,
132            reranked_score: original_score,
133            origin_label_digest: None,
134        }
135    }
136
137    fn with_origin(mut self, origin: Option<&OriginAuthorityRecordV1>) -> Self {
138        self.origin_label_digest = origin.map(|record| record.label_digest.clone());
139        self.origin_authority_score =
140            origin.map_or(0.0, |record| origin_authority_score(&record.label));
141        self
142    }
143}
144
145/// Rerank while retaining the original SearchResult, including its lexical rank, vector rank,
146/// cosine similarity, and fused score. The new score is additive and separately inspectable.
147pub fn rerank_state_aware(
148    mut candidates: Vec<StateRerankCandidateV1>,
149    weights: StateRerankWeightsV1,
150) -> Vec<StateRerankCandidateV1> {
151    for candidate in &mut candidates {
152        candidate.reranked_score = candidate.original_score
153            + weights.state_relevance * candidate.state_relevance
154            + weights.support * candidate.support_score
155            + weights.origin_authority * candidate.origin_authority_score;
156    }
157    candidates.sort_by(|a, b| {
158        b.reranked_score.total_cmp(&a.reranked_score).then_with(|| {
159            a.result
160                .source
161                .result_id()
162                .cmp(&b.result.source.result_id())
163        })
164    });
165    candidates
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct EvidencePacketItemV1 {
170    pub result: SearchResult,
171    pub state_status: PremiseStatus,
172    pub state_relevance: f64,
173    pub support_score: f64,
174    pub origin_authority_score: f64,
175    pub original_score: f64,
176    pub reranked_score: f64,
177    pub origin_label_digest: Option<String>,
178}
179
180impl From<StateRerankCandidateV1> for EvidencePacketItemV1 {
181    fn from(value: StateRerankCandidateV1) -> Self {
182        Self {
183            result: value.result,
184            state_status: value.state_status,
185            state_relevance: value.state_relevance,
186            support_score: value.support_score,
187            origin_authority_score: value.origin_authority_score,
188            original_score: value.original_score,
189            reranked_score: value.reranked_score,
190            origin_label_digest: value.origin_label_digest,
191        }
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct EvidenceAblationReceiptV1 {
197    pub schema_version: String,
198    pub baseline_result_ids: Vec<String>,
199    pub gap_loop_result_ids: Vec<String>,
200    pub packet_digest_without_gap_loop: String,
201    pub packet_digest_with_gap_loop: String,
202    pub answer_without_gap_loop: Option<String>,
203    pub answer_with_gap_loop: Option<String>,
204    pub baseline_outcome: EvidenceTerminalOutcomeV1,
205    pub final_outcome: EvidenceTerminalOutcomeV1,
206    pub packet_changed: bool,
207    pub order_changed: bool,
208    pub answer_changed: bool,
209    pub improved: bool,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct EvidenceGapRequestV1 {
214    pub schema_version: String,
215    pub request_id: String,
216    pub query: String,
217    pub top_k: usize,
218    pub namespaces: Option<Vec<String>>,
219    pub state_mode: StateResolutionMode,
220    pub budget: usize,
221    pub follow_up_limit: usize,
222    pub required_evidence: Vec<String>,
223    pub direct_ids: Vec<String>,
224    pub access_request: Option<GovernedAccessRequestV1>,
225    pub rerank_weights: StateRerankWeightsV1,
226}
227
228impl EvidenceGapRequestV1 {
229    pub fn new(query: impl Into<String>, state_view: StateView) -> Self {
230        let state_mode = match state_view {
231            StateView::Current => StateResolutionMode::Current,
232            StateView::HistoricalAt(value) => StateResolutionMode::HistoricalAt(value),
233            StateView::RecordedAsOf(value) => StateResolutionMode::HistoricalAt(value),
234            StateView::IncludeSuperseded => StateResolutionMode::Trajectory { points: Vec::new() },
235        };
236        let query = query.into();
237        Self {
238            schema_version: EVIDENCE_GAP_V1.into(),
239            request_id: format!("evidence-gap:{}", uuid::Uuid::new_v4()),
240            query,
241            top_k: 8,
242            namespaces: None,
243            state_mode,
244            budget: 8,
245            follow_up_limit: 4,
246            required_evidence: Vec::new(),
247            direct_ids: Vec::new(),
248            access_request: None,
249            rerank_weights: StateRerankWeightsV1::default(),
250        }
251    }
252
253    pub fn from_mode(query: impl Into<String>, state_mode: StateResolutionMode) -> Self {
254        let mut request = Self::new(query, state_mode.state_view());
255        request.state_mode = state_mode;
256        request
257    }
258
259    pub fn with_required_evidence(mut self, evidence: Vec<String>) -> Self {
260        self.required_evidence = evidence;
261        self
262    }
263
264    pub fn with_access_request(mut self, request: GovernedAccessRequestV1) -> Self {
265        self.access_request = Some(request);
266        self
267    }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct EvidencePacketV1 {
272    pub schema_version: String,
273    pub packet_id: String,
274    pub request_id: String,
275    pub query: String,
276    pub state_view: StateView,
277    pub outcome: EvidenceTerminalOutcomeV1,
278    pub items: Vec<EvidencePacketItemV1>,
279    pub gaps: Vec<EvidenceGapV1>,
280    pub routes: Vec<EvidenceRouteReceiptV1>,
281    pub answer: Option<String>,
282    pub answer_policy: AnswerPolicyDecision,
283    pub state_resolution_receipt: StateResolutionReceiptV1,
284    pub retrieval_witness: RetrievalWitnessV1,
285    pub ablation: EvidenceAblationReceiptV1,
286    pub packet_digest: String,
287}
288
289fn origin_authority_score(label: &crate::OriginAuthorityLabelV1) -> f64 {
290    if label.revocation_status != crate::RevocationStatusV1::Active {
291        return 0.0;
292    }
293    let risk = match label.risk {
294        OriginRiskV1::Low => 1.0,
295        OriginRiskV1::Medium => 0.75,
296        OriginRiskV1::High => 0.5,
297        OriginRiskV1::Critical => 0.25,
298    };
299    let scope = match label.scopes.recall {
300        AuthorityScopeV1::Universal => 1.0,
301        AuthorityScopeV1::Audience => 0.8,
302        AuthorityScopeV1::PrincipalOnly => 0.5,
303        AuthorityScopeV1::Denied => 0.0,
304    };
305    risk * scope
306}
307
308fn state_relevance(status: PremiseStatus) -> f64 {
309    state_relevance_for_status(status)
310}
311
312fn state_relevance_for_status(status: PremiseStatus) -> f64 {
313    match status {
314        PremiseStatus::Supported => 1.0,
315        PremiseStatus::Unsupported => 0.35,
316        PremiseStatus::Ambiguous => 0.15,
317        PremiseStatus::Stale => 0.05,
318        PremiseStatus::Contradicted => 0.0,
319    }
320}
321
322fn normalized(value: &str) -> String {
323    value
324        .split_whitespace()
325        .collect::<Vec<_>>()
326        .join(" ")
327        .to_lowercase()
328}
329
330fn digest<T: Serialize>(value: &T) -> Result<String, MemoryError> {
331    let bytes = serde_json::to_vec(value).map_err(|error| {
332        MemoryError::Other(format!("evidence digest serialization failed: {error}"))
333    })?;
334    Ok(format!("blake3:{}", blake3::hash(&bytes).to_hex()))
335}
336
337fn ids(results: &[SearchResult]) -> Vec<String> {
338    results
339        .iter()
340        .map(|result| result.source.result_id())
341        .collect()
342}
343
344fn state_status_for(result: &SearchResult, resolution: &DependencyResolutionV1) -> PremiseStatus {
345    match resolution.status(&result.source.result_id()) {
346        Some(DependencyState::Invalid) => PremiseStatus::Stale,
347        Some(DependencyState::Uncertain) => PremiseStatus::Ambiguous,
348        Some(DependencyState::Pending) => PremiseStatus::Unsupported,
349        Some(DependencyState::Valid) | None => PremiseStatus::Supported,
350    }
351}
352
353fn edge_from_stored(edge: &crate::StoredGraphEdge) -> Option<crate::StateDependencyEdgeV1> {
354    let graph_type = edge
355        .edge_type_parsed
356        .clone()
357        .or_else(|| serde_json::from_str(&edge.edge_type).ok())?;
358    let GraphEdgeType::Entity { relation } = graph_type else {
359        return None;
360    };
361    match relation.as_str() {
362        "invalidates" => Some(crate::StateDependencyEdgeV1::invalidates(
363            &edge.source,
364            &edge.target,
365        )),
366        "weakens" => Some(crate::StateDependencyEdgeV1::weakens(
367            &edge.source,
368            &edge.target,
369        )),
370        "requires_reevaluation" => Some(crate::StateDependencyEdgeV1::requires_reevaluation(
371            &edge.source,
372            &edge.target,
373        )),
374        "scope_changes" => Some(crate::StateDependencyEdgeV1::scope_changes(
375            &edge.source,
376            &edge.target,
377            "unknown",
378            "unknown",
379        )),
380        "derived_from_state" => Some(crate::StateDependencyEdgeV1::derived_from_state(
381            &edge.source,
382            &edge.target,
383        )),
384        _ => None,
385    }
386}
387
388fn required_satisfied(
389    results: &[SearchResult],
390    required: &[String],
391    retrieval_queries: &[String],
392) -> bool {
393    required.iter().all(|requirement| {
394        let needle = normalized(requirement);
395        results.iter().any(|result| {
396            normalized(&result.content).contains(&needle)
397                && retrieval_queries
398                    .iter()
399                    .any(|query| normalized(query).contains(&needle))
400        })
401    })
402}
403
404fn evidence_sufficient(
405    results: &[SearchResult],
406    required: &[String],
407    retrieval_queries: &[String],
408    resolution: &DependencyResolutionV1,
409) -> bool {
410    !results.is_empty()
411        && required_satisfied(results, required, retrieval_queries)
412        && results.iter().any(|result| {
413            matches!(
414                state_status_for(result, resolution),
415                PremiseStatus::Supported
416            )
417        })
418}
419
420fn diagnose_gaps(
421    results: &[SearchResult],
422    required: &[String],
423    retrieval_queries: &[String],
424    resolution: &DependencyResolutionV1,
425    origin_denied: bool,
426    query: &str,
427) -> Vec<EvidenceGapV1> {
428    let mut gaps = Vec::new();
429    let missing = required
430        .iter()
431        .filter(|requirement| {
432            !results.iter().any(|result| {
433                normalized(&result.content).contains(&normalized(requirement))
434                    && retrieval_queries
435                        .iter()
436                        .any(|q| normalized(q).contains(&normalized(requirement)))
437            })
438        })
439        .cloned()
440        .collect::<Vec<_>>();
441    if !missing.is_empty() {
442        gaps.push(EvidenceGapV1 {
443            schema_version: EVIDENCE_GAP_V1.into(),
444            gap_id: format!(
445                "gap:{}",
446                blake3::hash(missing.join("\n").as_bytes()).to_hex()
447            ),
448            reason: EvidenceGapReasonV1::MissingRequiredEvidence,
449            requirement: "required evidence terms".into(),
450            missing_items: missing.clone(),
451            diagnosed_by: vec!["content_and_route_coverage".into()],
452            follow_up_queries: missing.clone(),
453        });
454    }
455    if origin_denied {
456        gaps.push(EvidenceGapV1 {
457            schema_version: EVIDENCE_GAP_V1.into(),
458            gap_id: "gap:origin-authority".into(),
459            reason: EvidenceGapReasonV1::MissingOriginAuthority,
460            requirement: "origin-authorized recall".into(),
461            missing_items: vec!["origin authority denied or absent".into()],
462            diagnosed_by: vec!["governed_direct_id_or_search".into()],
463            follow_up_queries: Vec::new(),
464        });
465    }
466    if !results.is_empty()
467        && results
468            .iter()
469            .all(|result| state_status_for(result, resolution) != PremiseStatus::Supported)
470    {
471        gaps.push(EvidenceGapV1 {
472            schema_version: EVIDENCE_GAP_V1.into(),
473            gap_id: "gap:state-support".into(),
474            reason: EvidenceGapReasonV1::MissingStateSupport,
475            requirement: "current state support".into(),
476            missing_items: vec!["a supported current premise".into()],
477            diagnosed_by: vec!["state_dependency_resolution".into()],
478            follow_up_queries: vec![query.to_string()],
479        });
480    }
481    if results.is_empty() && !origin_denied && missing.is_empty() {
482        gaps.push(EvidenceGapV1 {
483            schema_version: EVIDENCE_GAP_V1.into(),
484            gap_id: "gap:no-candidates".into(),
485            reason: EvidenceGapReasonV1::NoCandidates,
486            requirement: "retrievable evidence".into(),
487            missing_items: vec![query.to_string()],
488            diagnosed_by: vec!["hybrid_search_empty".into()],
489            follow_up_queries: vec![query.to_string()],
490        });
491    }
492    gaps
493}
494
495fn resolution_status(
496    resolution: &DependencyResolutionV1,
497    results: &[SearchResult],
498) -> PremiseStatus {
499    if resolution.invalid_lineage || resolution.unresolved_conflict {
500        PremiseStatus::Ambiguous
501    } else if results.is_empty() {
502        PremiseStatus::Unsupported
503    } else if results
504        .iter()
505        .any(|result| state_status_for(result, resolution) == PremiseStatus::Supported)
506    {
507        PremiseStatus::Supported
508    } else if results
509        .iter()
510        .all(|result| state_status_for(result, resolution) == PremiseStatus::Stale)
511    {
512        PremiseStatus::Stale
513    } else {
514        PremiseStatus::Unsupported
515    }
516}
517
518fn packet_digest_input(items: &[EvidencePacketItemV1]) -> Vec<serde_json::Value> {
519    items
520        .iter()
521        .map(|item| {
522            serde_json::json!({
523                "id": item.result.source.result_id(),
524                "original_score": item.original_score,
525                "reranked_score": item.reranked_score,
526                "state_status": item.state_status,
527                "state_relevance": item.state_relevance,
528                "support_score": item.support_score,
529                "origin_authority_score": item.origin_authority_score,
530            })
531        })
532        .collect()
533}
534
535impl MemoryStore {
536    /// Retrieve a bounded evidence packet. Every terminal result includes both a state-resolution
537    /// receipt and a witnessed retrieval envelope at answer level.
538    pub async fn retrieve_evidence(
539        &self,
540        request: EvidenceGapRequestV1,
541    ) -> Result<EvidencePacketV1, MemoryError> {
542        let state_view = request.state_mode.state_view();
543        let namespaces = request
544            .namespaces
545            .as_ref()
546            .map(|values| values.iter().map(String::as_str).collect::<Vec<_>>());
547        let namespace_refs = namespaces.as_deref();
548        let mut routes = Vec::new();
549        let mut consumed = 0usize;
550        let mut origin_denied = false;
551        let mut retrieval_queries = vec![request.query.clone()];
552        let mut results = Vec::new();
553
554        if request.budget > 0 {
555            consumed += 1;
556            let initial = if matches!(state_view, StateView::Current) {
557                let mut context = SearchContext::default_now();
558                context.request_id = Some(request.request_id.clone());
559                context.receipt_mode = crate::ReceiptMode::ReturnReceipt;
560                self.search_with_context(
561                    &request.query,
562                    Some(request.top_k),
563                    namespace_refs,
564                    None,
565                    context,
566                )
567                .await?
568                .results
569            } else {
570                self.search_with_view(
571                    &request.query,
572                    Some(request.top_k),
573                    namespace_refs,
574                    None,
575                    state_view.clone(),
576                )
577                .await?
578            };
579            let (admitted, denied) = self
580                .admit_results(initial, request.access_request.as_ref())
581                .await?;
582            origin_denied |= denied;
583            results.extend(admitted);
584            routes.push(EvidenceRouteReceiptV1 {
585                schema_version: EVIDENCE_ROUTE_RECEIPT_V1.into(),
586                route: EvidenceRetrievalRouteV1::InitialHybrid,
587                query: Some(request.query.clone()),
588                stage_outcome: StageOutcomeV1::Applied,
589                result_ids: ids(&results),
590                order_changed: false,
591                budget_unit: consumed,
592                note: None,
593            });
594        }
595
596        let (baseline_resolution, baseline_edges) = self
597            .resolve_evidence_state(&results, request.budget)
598            .await?;
599        let baseline_gaps = diagnose_gaps(
600            &results,
601            &request.required_evidence,
602            &retrieval_queries,
603            &baseline_resolution,
604            origin_denied,
605            &request.query,
606        );
607        let baseline_invalid = baseline_resolution.invalid_lineage;
608        let baseline_conflict = baseline_resolution.unresolved_conflict;
609        let baseline_sufficient = request.budget > 0
610            && !baseline_invalid
611            && !baseline_conflict
612            && evidence_sufficient(
613                &results,
614                &request.required_evidence,
615                &retrieval_queries,
616                &baseline_resolution,
617            );
618        let baseline_outcome = if request.budget == 0 {
619            EvidenceTerminalOutcomeV1::BudgetExceeded
620        } else if baseline_invalid {
621            EvidenceTerminalOutcomeV1::InvalidLineage
622        } else if baseline_conflict {
623            EvidenceTerminalOutcomeV1::Conflict
624        } else if baseline_sufficient {
625            EvidenceTerminalOutcomeV1::Sufficient
626        } else {
627            EvidenceTerminalOutcomeV1::EvidenceInsufficient
628        };
629        let baseline_items = self
630            .build_rerank_candidates(
631                &results,
632                &baseline_resolution,
633                request.access_request.as_ref(),
634            )
635            .await?;
636        let baseline_packet_digest = digest(&packet_digest_input(
637            &baseline_items
638                .iter()
639                .cloned()
640                .map(EvidencePacketItemV1::from)
641                .collect::<Vec<_>>(),
642        ))?;
643        let baseline_answer = baseline_items
644            .first()
645            .filter(|_| baseline_sufficient)
646            .map(|candidate| candidate.result.content.clone());
647
648        let mut gaps = baseline_gaps;
649        let mut final_outcome = baseline_outcome;
650        if request.budget > 0 && !baseline_invalid && !baseline_conflict && !baseline_sufficient {
651            let follow_limit = request.follow_up_limit.min(4);
652            let follow_queries = gaps
653                .iter()
654                .flat_map(|gap| gap.follow_up_queries.iter().cloned())
655                .filter(|query| !query.trim().is_empty())
656                .collect::<Vec<_>>();
657            for query in follow_queries.into_iter().take(follow_limit) {
658                if consumed >= request.budget {
659                    break;
660                }
661                consumed += 1;
662                retrieval_queries.push(query.clone());
663                let before = ids(&results);
664                let found = if matches!(state_view, StateView::Current) {
665                    self.search_fts_only(
666                        &query,
667                        Some(request.top_k),
668                        namespace_refs,
669                        Some(&[SearchSourceType::Facts, SearchSourceType::Chunks]),
670                    )
671                    .await?
672                } else {
673                    self.search_with_view(
674                        &query,
675                        Some(request.top_k),
676                        namespace_refs,
677                        None,
678                        state_view.clone(),
679                    )
680                    .await?
681                };
682                let (admitted, denied) = self
683                    .admit_results(found, request.access_request.as_ref())
684                    .await?;
685                origin_denied |= denied;
686                merge_results(&mut results, admitted);
687                let after = ids(&results);
688                routes.push(EvidenceRouteReceiptV1 {
689                    schema_version: EVIDENCE_ROUTE_RECEIPT_V1.into(),
690                    route: EvidenceRetrievalRouteV1::Lexical,
691                    query: Some(query.clone()),
692                    stage_outcome: StageOutcomeV1::Applied,
693                    result_ids: after.clone(),
694                    order_changed: before != after,
695                    budget_unit: consumed,
696                    note: Some("required evidence follow-up".into()),
697                });
698                let (resolution, _) = self
699                    .resolve_evidence_state(&results, request.budget)
700                    .await?;
701                if evidence_sufficient(
702                    &results,
703                    &request.required_evidence,
704                    &retrieval_queries,
705                    &resolution,
706                ) {
707                    final_outcome = EvidenceTerminalOutcomeV1::Sufficient;
708                    break;
709                }
710            }
711        }
712
713        // A bounded vector follow-up is useful when lexical evidence is absent. It is deliberately
714        // separate from the lexical route so the receipt proves which lane changed the packet.
715        if request.budget > consumed
716            && !matches!(
717                final_outcome,
718                EvidenceTerminalOutcomeV1::Sufficient
719                    | EvidenceTerminalOutcomeV1::Conflict
720                    | EvidenceTerminalOutcomeV1::InvalidLineage
721            )
722            && request.follow_up_limit >= 2
723        {
724            consumed += 1;
725            let before = ids(&results);
726            let found = if matches!(state_view, StateView::Current) {
727                let mut context = SearchContext::default_now();
728                context.request_id = Some(format!("{}:vector", request.request_id));
729                context.receipt_mode = crate::ReceiptMode::ReturnReceipt;
730                self.search_vector_only_with_context(
731                    &request.query,
732                    Some(request.top_k),
733                    namespace_refs,
734                    None,
735                    context,
736                )
737                .await?
738                .results
739            } else {
740                Vec::new()
741            };
742            let (admitted, denied) = self
743                .admit_results(found, request.access_request.as_ref())
744                .await?;
745            origin_denied |= denied;
746            merge_results(&mut results, admitted);
747            let after = ids(&results);
748            routes.push(EvidenceRouteReceiptV1 {
749                schema_version: EVIDENCE_ROUTE_RECEIPT_V1.into(),
750                route: EvidenceRetrievalRouteV1::Vector,
751                query: Some(request.query.clone()),
752                stage_outcome: StageOutcomeV1::Applied,
753                result_ids: after.clone(),
754                order_changed: before != after,
755                budget_unit: consumed,
756                note: Some("bounded vector follow-up".into()),
757            });
758        }
759
760        // Graph expansion is bounded by both the caller budget and the graph API node cap.
761        if request.budget > consumed
762            && !matches!(
763                final_outcome,
764                EvidenceTerminalOutcomeV1::Conflict | EvidenceTerminalOutcomeV1::InvalidLineage
765            )
766            && request.follow_up_limit >= 3
767        {
768            consumed += 1;
769            let before = ids(&results);
770            let graph_results = self
771                .graph_follow_up(&results, request.access_request.as_ref(), request.top_k)
772                .await?;
773            let (admitted, denied) = self
774                .admit_results(graph_results, request.access_request.as_ref())
775                .await?;
776            origin_denied |= denied;
777            merge_results(&mut results, admitted);
778            let after = ids(&results);
779            routes.push(EvidenceRouteReceiptV1 {
780                schema_version: EVIDENCE_ROUTE_RECEIPT_V1.into(),
781                route: EvidenceRetrievalRouteV1::Graph,
782                query: None,
783                stage_outcome: StageOutcomeV1::Applied,
784                result_ids: after.clone(),
785                order_changed: before != after,
786                budget_unit: consumed,
787                note: Some("bounded state-neighborhood expansion".into()),
788            });
789        }
790
791        if request.budget > consumed
792            && !matches!(
793                final_outcome,
794                EvidenceTerminalOutcomeV1::Conflict | EvidenceTerminalOutcomeV1::InvalidLineage
795            )
796            && request.follow_up_limit >= 4
797        {
798            consumed += 1;
799            let before = ids(&results);
800            let found = if matches!(state_view, StateView::Current) {
801                self.search_with_view(
802                    &request.query,
803                    Some(request.top_k),
804                    namespace_refs,
805                    Some(&[SearchSourceType::Episodes]),
806                    state_view.clone(),
807                )
808                .await?
809            } else {
810                self.search_with_view(
811                    &request.query,
812                    Some(request.top_k),
813                    namespace_refs,
814                    Some(&[SearchSourceType::Episodes]),
815                    state_view.clone(),
816                )
817                .await?
818            };
819            let (admitted, denied) = self
820                .admit_results(found, request.access_request.as_ref())
821                .await?;
822            origin_denied |= denied;
823            merge_results(&mut results, admitted);
824            let after = ids(&results);
825            routes.push(EvidenceRouteReceiptV1 {
826                schema_version: EVIDENCE_ROUTE_RECEIPT_V1.into(),
827                route: EvidenceRetrievalRouteV1::Episodic,
828                query: Some(request.query.clone()),
829                stage_outcome: StageOutcomeV1::Applied,
830                result_ids: after.clone(),
831                order_changed: before != after,
832                budget_unit: consumed,
833                note: Some("bounded episodic follow-up".into()),
834            });
835        }
836
837        if !request.direct_ids.is_empty() {
838            let direct = self
839                .direct_id_results(&request.direct_ids, request.access_request.as_ref())
840                .await?;
841            if direct.1 {
842                origin_denied = true;
843            }
844            merge_results(&mut results, direct.0);
845            routes.push(EvidenceRouteReceiptV1 {
846                schema_version: EVIDENCE_ROUTE_RECEIPT_V1.into(),
847                route: EvidenceRetrievalRouteV1::DirectId,
848                query: None,
849                stage_outcome: if origin_denied {
850                    StageOutcomeV1::Degraded
851                } else {
852                    StageOutcomeV1::Applied
853                },
854                result_ids: ids(&results),
855                order_changed: false,
856                budget_unit: 0,
857                note: Some("direct IDs remain subject to governed origin admission".into()),
858            });
859        }
860
861        let (resolution, edges) = self
862            .resolve_evidence_state(&results, request.budget)
863            .await?;
864        let state_status = resolution_status(&resolution, &results);
865        let sufficient = request.budget > 0
866            && !resolution.invalid_lineage
867            && !resolution.unresolved_conflict
868            && evidence_sufficient(
869                &results,
870                &request.required_evidence,
871                &retrieval_queries,
872                &resolution,
873            );
874        final_outcome = if resolution.invalid_lineage {
875            EvidenceTerminalOutcomeV1::InvalidLineage
876        } else if resolution.unresolved_conflict {
877            EvidenceTerminalOutcomeV1::Conflict
878        } else if request.budget == 0
879            || (consumed >= request.budget && !sufficient && !results.is_empty())
880        {
881            EvidenceTerminalOutcomeV1::BudgetExceeded
882        } else if sufficient {
883            EvidenceTerminalOutcomeV1::Sufficient
884        } else {
885            EvidenceTerminalOutcomeV1::EvidenceInsufficient
886        };
887        gaps = diagnose_gaps(
888            &results,
889            &request.required_evidence,
890            &retrieval_queries,
891            &resolution,
892            origin_denied,
893            &request.query,
894        );
895
896        let mut candidates = self
897            .build_rerank_candidates(&results, &resolution, request.access_request.as_ref())
898            .await?;
899        candidates = rerank_state_aware(candidates, request.rerank_weights.clone());
900        candidates.truncate(request.top_k);
901        let items = candidates
902            .into_iter()
903            .map(EvidencePacketItemV1::from)
904            .collect::<Vec<_>>();
905        let final_packet_digest = digest(&packet_digest_input(&items))?;
906        let answer_policy = answer_policy_for(
907            state_status,
908            sufficient,
909            matches!(final_outcome, EvidenceTerminalOutcomeV1::Conflict),
910            matches!(final_outcome, EvidenceTerminalOutcomeV1::InvalidLineage),
911            matches!(final_outcome, EvidenceTerminalOutcomeV1::BudgetExceeded),
912        );
913        let answer = if matches!(final_outcome, EvidenceTerminalOutcomeV1::Sufficient) {
914            items.first().map(|item| item.result.content.clone())
915        } else {
916            None
917        };
918        let baseline_ids = baseline_items
919            .iter()
920            .map(|candidate| candidate.result.source.result_id())
921            .collect::<Vec<_>>();
922        let final_ids = items
923            .iter()
924            .map(|item| item.result.source.result_id())
925            .collect::<Vec<_>>();
926        let baseline_digest = baseline_packet_digest.clone();
927        let packet_changed = baseline_digest != final_packet_digest;
928        let order_changed = baseline_ids != final_ids;
929        let answer_changed = baseline_answer != answer;
930        let improved = matches!(final_outcome, EvidenceTerminalOutcomeV1::Sufficient)
931            && !matches!(baseline_outcome, EvidenceTerminalOutcomeV1::Sufficient)
932            && (packet_changed || answer_changed);
933        let ablation = EvidenceAblationReceiptV1 {
934            schema_version: EVIDENCE_PACKET_V1.into(),
935            baseline_result_ids: baseline_ids,
936            gap_loop_result_ids: final_ids,
937            packet_digest_without_gap_loop: baseline_digest,
938            packet_digest_with_gap_loop: final_packet_digest.clone(),
939            answer_without_gap_loop: baseline_answer,
940            answer_with_gap_loop: answer.clone(),
941            baseline_outcome,
942            final_outcome,
943            packet_changed,
944            order_changed,
945            answer_changed,
946            improved,
947        };
948
949        let resolved_ids = items
950            .iter()
951            .map(|item| item.result.source.result_id())
952            .collect::<Vec<_>>();
953        let alternative_ids = resolved_ids.iter().skip(1).cloned().collect::<Vec<_>>();
954        let mut state_receipt = StateResolutionReceiptV1::new(
955            request.request_id.clone(),
956            state_view.clone(),
957            request.state_mode.clone(),
958            state_status,
959            answer_policy.disposition,
960            resolved_ids,
961            alternative_ids,
962            sufficient,
963            matches!(final_outcome, EvidenceTerminalOutcomeV1::Conflict),
964            matches!(final_outcome, EvidenceTerminalOutcomeV1::InvalidLineage),
965            matches!(final_outcome, EvidenceTerminalOutcomeV1::BudgetExceeded),
966        )?;
967        state_receipt.stage_outcomes.push((
968            "evidence_gap".into(),
969            if matches!(final_outcome, EvidenceTerminalOutcomeV1::Sufficient) {
970                StageOutcomeV1::Applied
971            } else if matches!(final_outcome, EvidenceTerminalOutcomeV1::BudgetExceeded) {
972                StageOutcomeV1::BudgetExceeded
973            } else {
974                StageOutcomeV1::Degraded
975            },
976        ));
977        state_receipt = state_receipt.with_dependency_edge_digests(
978            edges
979                .iter()
980                .filter_map(edge_from_stored)
981                .map(|edge| edge.digest())
982                .collect(),
983        )?;
984        let witness = crate::state_epistemics::witnessed_retrieval(
985            self,
986            request.request_id.clone(),
987            &request.query,
988            &items
989                .iter()
990                .map(|item| item.result.clone())
991                .collect::<Vec<_>>(),
992        )
993        .await?;
994        let packet_id = format!("evidence-packet:{final_packet_digest}");
995        Ok(EvidencePacketV1 {
996            schema_version: EVIDENCE_PACKET_V1.into(),
997            packet_id,
998            request_id: request.request_id,
999            query: request.query,
1000            state_view,
1001            outcome: final_outcome,
1002            items,
1003            gaps,
1004            routes,
1005            answer,
1006            answer_policy,
1007            state_resolution_receipt: state_receipt,
1008            retrieval_witness: witness,
1009            ablation,
1010            packet_digest: final_packet_digest,
1011        })
1012    }
1013
1014    pub async fn retrieve_evidence_gap(
1015        &self,
1016        request: EvidenceGapRequestV1,
1017    ) -> Result<EvidencePacketV1, MemoryError> {
1018        self.retrieve_evidence(request).await
1019    }
1020
1021    async fn admit_results(
1022        &self,
1023        results: Vec<SearchResult>,
1024        access: Option<&GovernedAccessRequestV1>,
1025    ) -> Result<(Vec<SearchResult>, bool), MemoryError> {
1026        let Some(request) = access else {
1027            // Evidence packets are governed public output. Missing access context is a denial,
1028            // not a legacy/raw fallback: otherwise a cache or follow-up route could disclose
1029            // content that direct governed retrieval correctly withholds.
1030            return Ok((Vec::new(), !results.is_empty()));
1031        };
1032        let mut admitted = Vec::new();
1033        let mut denied = false;
1034        for result in results {
1035            let allowed = match &result.source {
1036                SearchSource::Fact { fact_id, .. } => {
1037                    self.authority()
1038                        .get_fact_governed(fact_id, request.clone())
1039                        .await?
1040                        .decision
1041                        .allowed
1042                }
1043                _ => false,
1044            };
1045            if allowed {
1046                admitted.push(result);
1047            } else {
1048                denied = true;
1049            }
1050        }
1051        Ok((admitted, denied))
1052    }
1053
1054    async fn direct_id_results(
1055        &self,
1056        direct_ids: &[String],
1057        access: Option<&GovernedAccessRequestV1>,
1058    ) -> Result<(Vec<SearchResult>, bool), MemoryError> {
1059        let mut results = Vec::new();
1060        let mut denied = false;
1061        for raw_id in direct_ids {
1062            let fact_id = raw_id.strip_prefix("fact:").unwrap_or(raw_id);
1063            let fact = if let Some(request) = access {
1064                let governed = self
1065                    .authority()
1066                    .get_fact_governed(fact_id, request.clone())
1067                    .await?;
1068                if !governed.decision.allowed {
1069                    denied = true;
1070                    None
1071                } else {
1072                    governed.fact
1073                }
1074            } else {
1075                denied = true;
1076                None
1077            };
1078            if let Some(fact) = fact {
1079                results.push(SearchResult {
1080                    content: fact.content,
1081                    source: SearchSource::Fact {
1082                        fact_id: fact.id,
1083                        namespace: fact.namespace,
1084                    },
1085                    score: 0.0,
1086                    bm25_rank: None,
1087                    vector_rank: None,
1088                    cosine_similarity: None,
1089                });
1090            }
1091        }
1092        Ok((results, denied))
1093    }
1094
1095    async fn graph_follow_up(
1096        &self,
1097        results: &[SearchResult],
1098        access: Option<&GovernedAccessRequestV1>,
1099        top_k: usize,
1100    ) -> Result<Vec<SearchResult>, MemoryError> {
1101        let seeds = results
1102            .iter()
1103            .filter_map(|result| match &result.source {
1104                SearchSource::Fact { fact_id, .. } => Some(format!("fact:{fact_id}")),
1105                _ => None,
1106            })
1107            .collect::<Vec<_>>();
1108        if seeds.is_empty() {
1109            return Ok(Vec::new());
1110        }
1111        let edges = self
1112            .list_graph_edges_for_neighborhood(seeds, 1, top_k.clamp(1, 32))
1113            .await?;
1114        let mut ids = BTreeSet::new();
1115        for edge in edges {
1116            for endpoint in [&edge.source, &edge.target] {
1117                if endpoint.starts_with("fact:") {
1118                    ids.insert(endpoint.trim_start_matches("fact:").to_string());
1119                }
1120            }
1121        }
1122        let (facts, _) = self
1123            .direct_id_results(
1124                &ids.into_iter()
1125                    .map(|id| format!("fact:{id}"))
1126                    .collect::<Vec<_>>(),
1127                access,
1128            )
1129            .await?;
1130        Ok(facts)
1131    }
1132
1133    async fn resolve_evidence_state(
1134        &self,
1135        results: &[SearchResult],
1136        budget: usize,
1137    ) -> Result<(DependencyResolutionV1, Vec<crate::StoredGraphEdge>), MemoryError> {
1138        let roots = results
1139            .iter()
1140            .filter_map(|result| match &result.source {
1141                SearchSource::Fact { fact_id, .. } => Some(format!("fact:{fact_id}")),
1142                _ => None,
1143            })
1144            .collect::<Vec<_>>();
1145        if roots.is_empty() {
1146            return Ok((
1147                resolve_dependency_states(&[], &[], budget.max(1)),
1148                Vec::new(),
1149            ));
1150        }
1151        let edges = self
1152            .list_graph_edges_for_neighborhood(roots.clone(), 2, budget.clamp(1, 128))
1153            .await?;
1154        let typed = edges
1155            .iter()
1156            .filter_map(edge_from_stored)
1157            .collect::<Vec<_>>();
1158        Ok((
1159            resolve_dependency_states(&typed, &roots, budget.max(1)),
1160            edges,
1161        ))
1162    }
1163
1164    async fn build_rerank_candidates(
1165        &self,
1166        results: &[SearchResult],
1167        resolution: &DependencyResolutionV1,
1168        access: Option<&GovernedAccessRequestV1>,
1169    ) -> Result<Vec<StateRerankCandidateV1>, MemoryError> {
1170        let mut candidates = Vec::new();
1171        for result in results {
1172            let status = state_status_for(result, resolution);
1173            let origin = match (&result.source, access) {
1174                (SearchSource::Fact { fact_id, .. }, _) => {
1175                    self.authority().get_origin_authority(fact_id).await?
1176                }
1177                _ => None,
1178            };
1179            let candidate = StateRerankCandidateV1::from_result(
1180                result.clone(),
1181                status,
1182                state_relevance(status),
1183            )
1184            .with_origin(origin.as_ref());
1185            candidates.push(candidate);
1186        }
1187        Ok(candidates)
1188    }
1189}
1190
1191fn merge_results(results: &mut Vec<SearchResult>, additions: Vec<SearchResult>) {
1192    let mut seen = results
1193        .iter()
1194        .map(|result| result.source.result_id())
1195        .collect::<BTreeSet<_>>();
1196    for result in additions {
1197        if seen.insert(result.source.result_id()) {
1198            results.push(result);
1199        }
1200    }
1201}