Skip to main content

cortex_retrieval/
resolve.rs

1//! Deterministic contradiction-resolution scaffold for retrieval.
2//!
3//! This module is intentionally read-only. Store and context-pack wiring can
4//! adapt durable memory rows into these inputs later.
5
6use std::collections::HashSet;
7
8use cortex_core::{
9    compose_policy_outcomes, CoreError, CoreResult, PolicyContribution, PolicyDecision,
10    PolicyOutcome,
11};
12
13/// High-level result of attempting to resolve a conflict set.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ResolutionState {
16    /// A single candidate is safe to consume as the resolved memory.
17    Resolved,
18    /// Multiple hypotheses must be surfaced because no safe winner exists.
19    MultiHypothesis,
20    /// Proof, claim-slot, or precedence evidence is insufficient.
21    Unknown,
22}
23
24/// Coarse authority level supplied by the caller.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum AuthorityLevel {
27    /// Low authority — observed or unverified.
28    Low,
29    /// Medium authority — partially verified.
30    Medium,
31    /// High authority — fully verified or operator-grade.
32    High,
33}
34
35/// Authority proof closure hint for a candidate memory.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ProofClosureHint {
38    /// All proof axes required by the caller passed.
39    FullChainVerified,
40    /// At least one proof axis is intentionally unavailable or development-grade.
41    Partial,
42    /// Proof state has not been established.
43    Unknown,
44    /// A proof axis failed with a named edge.
45    Broken {
46        /// Name of the proof axis that failed.
47        edge: String,
48    },
49}
50
51impl ProofClosureHint {
52    fn is_full_chain_verified(&self) -> bool {
53        matches!(self, Self::FullChainVerified)
54    }
55}
56
57/// Authority and proof hints attached to a memory candidate.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct AuthorityProofHint {
60    /// Caller-provided authority level.
61    pub authority: AuthorityLevel,
62    /// Caller-provided proof closure result.
63    pub proof: ProofClosureHint,
64}
65
66impl AuthorityProofHint {
67    /// Returns true when a candidate has high authority and full proof closure.
68    #[must_use]
69    pub fn is_high_authority_verified(&self) -> bool {
70        self.authority == AuthorityLevel::High && self.proof.is_full_chain_verified()
71    }
72}
73
74/// Memory-shaped input for a caller-supplied contradiction set.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ConflictingMemoryInput {
77    /// Durable memory identifier.
78    pub memory_id: String,
79    /// Stable belief-slot key when known.
80    pub claim_key: Option<String>,
81    /// Memory claim text.
82    pub claim: String,
83    /// Authority and proof hints for this memory.
84    pub authority: AuthorityProofHint,
85    /// Memory IDs this input is known to conflict with.
86    pub conflicts_with: Vec<String>,
87}
88
89impl ConflictingMemoryInput {
90    /// Creates a memory input with no explicit `conflicts_with` edges.
91    #[must_use]
92    pub fn new(
93        memory_id: impl Into<String>,
94        claim_key: Option<impl Into<String>>,
95        claim: impl Into<String>,
96        authority: AuthorityProofHint,
97    ) -> Self {
98        Self {
99            memory_id: memory_id.into(),
100            claim_key: claim_key.map(Into::into),
101            claim: claim.into(),
102            authority,
103            conflicts_with: Vec::new(),
104        }
105    }
106
107    /// Adds explicit conflict edges.
108    #[must_use]
109    pub fn with_conflicts(mut self, conflicts_with: Vec<String>) -> Self {
110        self.conflicts_with = conflicts_with;
111        self
112    }
113}
114
115/// Explicit evidence that one candidate supersedes the others for this conflict set.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct PrecedenceEvidence {
118    /// Winning memory ID.
119    pub winner_memory_id: String,
120    /// Memory IDs explicitly superseded by `winner_memory_id`.
121    pub loser_memory_ids: Vec<String>,
122    /// Human- or policy-readable resolution reason.
123    pub reason: String,
124    /// Proof closure for the precedence evidence itself.
125    pub proof: ProofClosureHint,
126}
127
128/// Machine-readable reason emitted by the resolver.
129#[allow(missing_docs)]
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum ResolutionReason {
132    /// No candidate inputs were provided.
133    NoInputs,
134    /// Exactly one candidate was present; no conflict to resolve.
135    SingleCandidate,
136    /// All candidates share the same claim text.
137    DuplicateClaim,
138    /// An explicit precedence record nominated the winner.
139    ExplicitPrecedence { winner_memory_id: String },
140    /// Multiple candidates have conflicting claims.
141    ConflictingClaims,
142    /// A candidate is missing a required claim key.
143    MissingClaimKey { memory_id: String },
144    /// A candidate has only partial proof closure.
145    PartialProof { memory_id: String },
146    /// A candidate has unknown proof state.
147    UnknownProof { memory_id: String },
148    /// A candidate has a broken proof axis.
149    BrokenProof { memory_id: String, edge: String },
150    /// High-authority verified conflict requires explicit precedence before resolution.
151    HighAuthorityVerifiedConflictRequiresPrecedence,
152    /// Precedence evidence does not cover all losers.
153    IncompletePrecedence {
154        winner_memory_id: String,
155        missing_loser_ids: Vec<String>,
156    },
157    /// Multiple candidates could claim precedence; winner is ambiguous.
158    AmbiguousPrecedence { winner_memory_ids: Vec<String> },
159}
160
161impl ResolutionReason {
162    const fn policy_rule_id(&self) -> &'static str {
163        match self {
164            Self::NoInputs => "retrieval.resolve.no_inputs",
165            Self::SingleCandidate => "retrieval.resolve.single_candidate",
166            Self::DuplicateClaim => "retrieval.resolve.duplicate_claim",
167            Self::ExplicitPrecedence { .. } => "retrieval.resolve.explicit_precedence",
168            Self::ConflictingClaims => "retrieval.resolve.conflicting_claims",
169            Self::MissingClaimKey { .. } => "retrieval.resolve.missing_claim_key",
170            Self::PartialProof { .. } => "retrieval.resolve.partial_proof",
171            Self::UnknownProof { .. } => "retrieval.resolve.unknown_proof",
172            Self::BrokenProof { .. } => "retrieval.resolve.broken_proof",
173            Self::HighAuthorityVerifiedConflictRequiresPrecedence => {
174                "retrieval.resolve.high_authority_conflict_requires_precedence"
175            }
176            Self::IncompletePrecedence { .. } => "retrieval.resolve.incomplete_precedence",
177            Self::AmbiguousPrecedence { .. } => "retrieval.resolve.ambiguous_precedence",
178        }
179    }
180
181    const fn policy_outcome(&self, state: ResolutionState) -> PolicyOutcome {
182        match self {
183            Self::SingleCandidate | Self::DuplicateClaim | Self::ExplicitPrecedence { .. }
184                if matches!(state, ResolutionState::Resolved) =>
185            {
186                PolicyOutcome::Allow
187            }
188            Self::BrokenProof { .. } => PolicyOutcome::Reject,
189            _ => PolicyOutcome::Quarantine,
190        }
191    }
192
193    const fn policy_reason(&self) -> &'static str {
194        match self {
195            Self::NoInputs => "retrieval conflict resolver received no inputs",
196            Self::SingleCandidate => "single candidate can be consumed",
197            Self::DuplicateClaim => "duplicate claims resolved by deterministic authority ordering",
198            Self::ExplicitPrecedence { .. } => "explicit full-chain precedence resolved conflict",
199            Self::ConflictingClaims => "conflicting claims require multi-hypothesis handling",
200            Self::MissingClaimKey { .. } => "candidate is missing claim-key evidence",
201            Self::PartialProof { .. } => "candidate proof is partial",
202            Self::UnknownProof { .. } => "candidate proof is unknown",
203            Self::BrokenProof { .. } => "candidate proof is broken",
204            Self::HighAuthorityVerifiedConflictRequiresPrecedence => {
205                "high-authority verified conflict requires explicit precedence"
206            }
207            Self::IncompletePrecedence { .. } => "precedence evidence does not cover all losers",
208            Self::AmbiguousPrecedence { .. } => "multiple precedence winners remain ambiguous",
209        }
210    }
211}
212
213/// Resolver output consumed by later retrieval/context-pack wiring.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct ResolverOutput {
216    /// Resolution state.
217    pub state: ResolutionState,
218    /// Selected candidate when `state == Resolved`.
219    pub selected: Option<ConflictingMemoryInput>,
220    /// Candidate hypotheses that must be surfaced to callers.
221    pub hypotheses: Vec<ConflictingMemoryInput>,
222    /// Reasons supporting the state.
223    pub reasons: Vec<ResolutionReason>,
224}
225
226impl ResolverOutput {
227    /// Derive the ADR 0026 policy decision for this resolver output.
228    #[must_use]
229    pub fn policy_decision(&self) -> PolicyDecision {
230        if self.reasons.is_empty() {
231            return compose_policy_outcomes(
232                vec![PolicyContribution::new(
233                    "retrieval.resolve.no_reason",
234                    PolicyOutcome::Quarantine,
235                    "resolver returned no reason and cannot be treated as clean authority",
236                )
237                .expect("static policy contribution is valid")],
238                None,
239            );
240        }
241
242        let contributions = self
243            .reasons
244            .iter()
245            .map(|reason| {
246                PolicyContribution::new(
247                    reason.policy_rule_id(),
248                    reason.policy_outcome(self.state),
249                    reason.policy_reason(),
250                )
251                .expect("static policy contribution is valid")
252            })
253            .collect();
254        compose_policy_outcomes(contributions, None)
255    }
256
257    /// Fail closed before a resolver output is consumed as a default,
258    /// canonical retrieval result.
259    ///
260    /// Multi-hypothesis and unknown outputs may still be rendered explicitly,
261    /// but they must not silently act as resolved support for promotion,
262    /// active-memory updates, or default context-pack inclusion.
263    pub fn require_default_use_allowed(&self) -> CoreResult<()> {
264        let policy = self.policy_decision();
265        match policy.final_outcome {
266            PolicyOutcome::Reject | PolicyOutcome::Quarantine => {
267                Err(CoreError::Validation(format!(
268                    "retrieval resolver default use blocked by policy outcome {:?}",
269                    policy.final_outcome
270                )))
271            }
272            PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
273        }
274    }
275}
276
277/// Resolves a caller-supplied contradiction set without mutating memory.
278#[must_use]
279pub fn resolve_conflicts(
280    inputs: &[ConflictingMemoryInput],
281    precedence: &[PrecedenceEvidence],
282) -> ResolverOutput {
283    let candidates = sorted_candidates(inputs);
284    if candidates.is_empty() {
285        return ResolverOutput {
286            state: ResolutionState::Unknown,
287            selected: None,
288            hypotheses: Vec::new(),
289            reasons: vec![ResolutionReason::NoInputs],
290        };
291    }
292
293    let mut reasons = proof_reasons(&candidates);
294    reasons.extend(missing_claim_key_reasons(&candidates));
295    if !reasons.is_empty() {
296        return ResolverOutput {
297            state: ResolutionState::Unknown,
298            selected: None,
299            hypotheses: candidates,
300            reasons,
301        };
302    }
303
304    if candidates.len() == 1 {
305        return ResolverOutput {
306            state: ResolutionState::Resolved,
307            selected: candidates.first().cloned(),
308            hypotheses: candidates,
309            reasons: vec![ResolutionReason::SingleCandidate],
310        };
311    }
312
313    if !has_conflict(&candidates) {
314        let selected = strongest_candidate(&candidates);
315        return ResolverOutput {
316            state: ResolutionState::Resolved,
317            selected: Some(selected),
318            hypotheses: candidates,
319            reasons: vec![ResolutionReason::DuplicateClaim],
320        };
321    }
322
323    match valid_precedence_winner(&candidates, precedence) {
324        PrecedenceMatch::One(winner) => ResolverOutput {
325            state: ResolutionState::Resolved,
326            selected: Some(winner.clone()),
327            hypotheses: vec![winner.clone()],
328            reasons: vec![ResolutionReason::ExplicitPrecedence {
329                winner_memory_id: winner.memory_id,
330            }],
331        },
332        PrecedenceMatch::Many(winner_memory_ids) => ResolverOutput {
333            state: ResolutionState::MultiHypothesis,
334            selected: None,
335            hypotheses: candidates,
336            reasons: vec![ResolutionReason::AmbiguousPrecedence { winner_memory_ids }],
337        },
338        PrecedenceMatch::Incomplete {
339            winner_memory_id,
340            missing_loser_ids,
341        } => ResolverOutput {
342            state: ResolutionState::MultiHypothesis,
343            selected: None,
344            hypotheses: candidates,
345            reasons: vec![ResolutionReason::IncompletePrecedence {
346                winner_memory_id,
347                missing_loser_ids,
348            }],
349        },
350        PrecedenceMatch::None => unresolved_conflict(candidates),
351    }
352}
353
354fn unresolved_conflict(candidates: Vec<ConflictingMemoryInput>) -> ResolverOutput {
355    let mut reasons = vec![ResolutionReason::ConflictingClaims];
356    if high_authority_verified_conflict(&candidates) {
357        reasons.push(ResolutionReason::HighAuthorityVerifiedConflictRequiresPrecedence);
358    }
359
360    ResolverOutput {
361        state: ResolutionState::MultiHypothesis,
362        selected: None,
363        hypotheses: candidates,
364        reasons,
365    }
366}
367
368fn proof_reasons(candidates: &[ConflictingMemoryInput]) -> Vec<ResolutionReason> {
369    let mut reasons = Vec::new();
370    for candidate in candidates {
371        match &candidate.authority.proof {
372            ProofClosureHint::FullChainVerified => {}
373            ProofClosureHint::Partial => reasons.push(ResolutionReason::PartialProof {
374                memory_id: candidate.memory_id.clone(),
375            }),
376            ProofClosureHint::Unknown => reasons.push(ResolutionReason::UnknownProof {
377                memory_id: candidate.memory_id.clone(),
378            }),
379            ProofClosureHint::Broken { edge } => reasons.push(ResolutionReason::BrokenProof {
380                memory_id: candidate.memory_id.clone(),
381                edge: edge.clone(),
382            }),
383        }
384    }
385    reasons
386}
387
388fn missing_claim_key_reasons(candidates: &[ConflictingMemoryInput]) -> Vec<ResolutionReason> {
389    candidates
390        .iter()
391        .filter(|candidate| {
392            candidate
393                .claim_key
394                .as_ref()
395                .is_none_or(|claim_key| claim_key.trim().is_empty())
396        })
397        .map(|candidate| ResolutionReason::MissingClaimKey {
398            memory_id: candidate.memory_id.clone(),
399        })
400        .collect()
401}
402
403fn has_conflict(candidates: &[ConflictingMemoryInput]) -> bool {
404    let claims: HashSet<_> = candidates
405        .iter()
406        .map(|candidate| normalize_claim(&candidate.claim))
407        .collect();
408    if claims.len() > 1 {
409        return true;
410    }
411
412    let ids: HashSet<_> = candidates
413        .iter()
414        .map(|candidate| candidate.memory_id.as_str())
415        .collect();
416    candidates.iter().any(|candidate| {
417        candidate
418            .conflicts_with
419            .iter()
420            .any(|conflict_id| ids.contains(conflict_id.as_str()))
421    })
422}
423
424fn high_authority_verified_conflict(candidates: &[ConflictingMemoryInput]) -> bool {
425    candidates
426        .iter()
427        .filter(|candidate| candidate.authority.is_high_authority_verified())
428        .take(2)
429        .count()
430        > 1
431}
432
433fn strongest_candidate(candidates: &[ConflictingMemoryInput]) -> ConflictingMemoryInput {
434    let mut sorted = candidates.to_vec();
435    sorted.sort_by(|left, right| {
436        right
437            .authority
438            .authority
439            .cmp(&left.authority.authority)
440            .then_with(|| left.memory_id.cmp(&right.memory_id))
441    });
442    sorted
443        .into_iter()
444        .next()
445        .expect("strongest_candidate requires at least one candidate")
446}
447
448fn sorted_candidates(inputs: &[ConflictingMemoryInput]) -> Vec<ConflictingMemoryInput> {
449    let mut candidates = inputs.to_vec();
450    candidates.sort_by(|left, right| left.memory_id.cmp(&right.memory_id));
451    candidates
452}
453
454fn normalize_claim(claim: &str) -> String {
455    claim
456        .split_whitespace()
457        .collect::<Vec<_>>()
458        .join(" ")
459        .to_ascii_lowercase()
460}
461
462enum PrecedenceMatch {
463    One(ConflictingMemoryInput),
464    Many(Vec<String>),
465    Incomplete {
466        winner_memory_id: String,
467        missing_loser_ids: Vec<String>,
468    },
469    None,
470}
471
472fn valid_precedence_winner(
473    candidates: &[ConflictingMemoryInput],
474    precedence: &[PrecedenceEvidence],
475) -> PrecedenceMatch {
476    let candidate_ids: HashSet<_> = candidates
477        .iter()
478        .map(|candidate| candidate.memory_id.as_str())
479        .collect();
480    let mut complete_winners = Vec::new();
481    let mut first_incomplete = None;
482
483    for evidence in precedence
484        .iter()
485        .filter(|evidence| evidence.proof.is_full_chain_verified())
486        .filter(|evidence| candidate_ids.contains(evidence.winner_memory_id.as_str()))
487    {
488        let loser_ids: HashSet<_> = evidence
489            .loser_memory_ids
490            .iter()
491            .map(String::as_str)
492            .collect();
493        let mut missing_loser_ids: Vec<_> = candidate_ids
494            .iter()
495            .copied()
496            .filter(|candidate_id| *candidate_id != evidence.winner_memory_id)
497            .filter(|candidate_id| !loser_ids.contains(candidate_id))
498            .map(str::to_string)
499            .collect();
500        missing_loser_ids.sort();
501
502        if missing_loser_ids.is_empty() {
503            complete_winners.push(evidence.winner_memory_id.clone());
504        } else if first_incomplete.is_none() {
505            first_incomplete = Some(PrecedenceMatch::Incomplete {
506                winner_memory_id: evidence.winner_memory_id.clone(),
507                missing_loser_ids,
508            });
509        }
510    }
511
512    complete_winners.sort();
513    complete_winners.dedup();
514    match complete_winners.len() {
515        0 => first_incomplete.unwrap_or(PrecedenceMatch::None),
516        1 => {
517            let winner_id = &complete_winners[0];
518            let winner = candidates
519                .iter()
520                .find(|candidate| &candidate.memory_id == winner_id)
521                .expect("complete winner came from candidate IDs")
522                .clone();
523            PrecedenceMatch::One(winner)
524        }
525        _ => PrecedenceMatch::Many(complete_winners),
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn verified_high(memory_id: &str, claim: &str) -> ConflictingMemoryInput {
534        ConflictingMemoryInput::new(
535            memory_id,
536            Some("slot/runtime"),
537            claim,
538            AuthorityProofHint {
539                authority: AuthorityLevel::High,
540                proof: ProofClosureHint::FullChainVerified,
541            },
542        )
543    }
544
545    #[test]
546    fn conflicting_verified_memories_enter_multi_hypothesis() {
547        let left = verified_high("mem_a", "Use replay adapter version 1");
548        let right = verified_high("mem_b", "Use replay adapter version 2");
549
550        let output = resolve_conflicts(&[left, right], &[]);
551
552        assert_eq!(output.state, ResolutionState::MultiHypothesis);
553        assert_eq!(
554            output.policy_decision().final_outcome,
555            PolicyOutcome::Quarantine
556        );
557        assert_eq!(output.selected, None);
558        assert_eq!(output.hypotheses.len(), 2);
559        assert!(output
560            .reasons
561            .contains(&ResolutionReason::HighAuthorityVerifiedConflictRequiresPrecedence));
562    }
563
564    #[test]
565    fn unknown_proof_propagates_unknown() {
566        let input = ConflictingMemoryInput::new(
567            "mem_unknown",
568            Some("slot/runtime"),
569            "Use replay adapter version 1",
570            AuthorityProofHint {
571                authority: AuthorityLevel::High,
572                proof: ProofClosureHint::Unknown,
573            },
574        );
575
576        let output = resolve_conflicts(&[input], &[]);
577
578        assert_eq!(output.state, ResolutionState::Unknown);
579        assert_eq!(
580            output.policy_decision().final_outcome,
581            PolicyOutcome::Quarantine
582        );
583        assert_eq!(output.selected, None);
584        assert!(output.reasons.contains(&ResolutionReason::UnknownProof {
585            memory_id: "mem_unknown".into()
586        }));
587    }
588
589    #[test]
590    fn explicit_full_chain_precedence_resolves_conflict() {
591        let left = verified_high("mem_a", "Use replay adapter version 1");
592        let right = verified_high("mem_b", "Use replay adapter version 2");
593        let precedence = PrecedenceEvidence {
594            winner_memory_id: "mem_b".into(),
595            loser_memory_ids: vec!["mem_a".into()],
596            reason: "operator-attested supersession".into(),
597            proof: ProofClosureHint::FullChainVerified,
598        };
599
600        let output = resolve_conflicts(&[left, right], &[precedence]);
601
602        assert_eq!(output.state, ResolutionState::Resolved);
603        assert_eq!(output.policy_decision().final_outcome, PolicyOutcome::Allow);
604        assert_eq!(
605            output
606                .selected
607                .as_ref()
608                .map(|candidate| candidate.memory_id.as_str()),
609            Some("mem_b")
610        );
611        assert_eq!(
612            output.reasons,
613            [ResolutionReason::ExplicitPrecedence {
614                winner_memory_id: "mem_b".into()
615            }]
616        );
617        output
618            .require_default_use_allowed()
619            .expect("resolved output is default-usable");
620    }
621
622    #[test]
623    fn broken_proof_maps_to_policy_reject() {
624        let input = ConflictingMemoryInput::new(
625            "mem_broken",
626            Some("slot/runtime"),
627            "Use replay adapter version 1",
628            AuthorityProofHint {
629                authority: AuthorityLevel::High,
630                proof: ProofClosureHint::Broken {
631                    edge: "event:missing_hash".into(),
632                },
633            },
634        );
635
636        let output = resolve_conflicts(&[input], &[]);
637        let policy = output.policy_decision();
638
639        assert_eq!(output.state, ResolutionState::Unknown);
640        assert_eq!(policy.final_outcome, PolicyOutcome::Reject);
641        assert_eq!(
642            policy.contributing[0].rule_id.as_str(),
643            "retrieval.resolve.broken_proof"
644        );
645    }
646
647    #[test]
648    fn unresolved_conflict_fails_closed_for_default_use() {
649        let left = verified_high("mem_a", "Use replay adapter version 1");
650        let right = verified_high("mem_b", "Use replay adapter version 2");
651
652        let output = resolve_conflicts(&[left, right], &[]);
653        let err = output
654            .require_default_use_allowed()
655            .expect_err("multi-hypothesis output must not be default-usable");
656
657        assert!(
658            err.to_string().contains("Quarantine"),
659            "default-use failure should expose the policy outcome: {err}"
660        );
661    }
662
663    #[test]
664    fn broken_proof_fails_closed_for_default_use() {
665        let input = ConflictingMemoryInput::new(
666            "mem_broken",
667            Some("slot/runtime"),
668            "Use replay adapter version 1",
669            AuthorityProofHint {
670                authority: AuthorityLevel::High,
671                proof: ProofClosureHint::Broken {
672                    edge: "event:missing_hash".into(),
673                },
674            },
675        );
676
677        let output = resolve_conflicts(&[input], &[]);
678        let err = output
679            .require_default_use_allowed()
680            .expect_err("broken proof must not be default-usable");
681
682        assert!(
683            err.to_string().contains("Reject"),
684            "default-use failure should expose the policy outcome: {err}"
685        );
686    }
687}