Skip to main content

lean_ctx/core/semantic/
evidence.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Typed evidence attached to graph edges (`Edge.metadata`).
3//!
4//! An edge says *that* two things are related; its evidence says *how sure*
5//! lean-ctx is. Consumers weight edges by grade instead of treating a name
6//! match and a compiler-verified call as equal.
7
8use serde::{Deserialize, Serialize};
9
10use crate::core::call_graph::ScopeMatch;
11
12/// Strength of a relationship, weakest first (`Ord` = strength).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum EvidenceGrade {
16    /// Plausible by name only (e.g. a name unique in the project but not in
17    /// the caller's scope — could still be an external function).
18    HeuristicStructural,
19    /// Bound by the caller's own scope (same file / unique import) or by the
20    /// call's own module path (`config_io::write` → `config_io.rs`).
21    ResolvedStructural,
22    /// A local semantic backend (language server / IDE) resolved it.
23    VerifiedSemantic,
24}
25
26impl EvidenceGrade {
27    pub fn from_scope(via: ScopeMatch) -> Self {
28        match via {
29            ScopeMatch::SameFile | ScopeMatch::UniqueImport | ScopeMatch::ModulePath => {
30                Self::ResolvedStructural
31            }
32            ScopeMatch::UniqueInProject => Self::HeuristicStructural,
33        }
34    }
35
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::HeuristicStructural => "heuristic",
39            Self::ResolvedStructural => "resolved",
40            Self::VerifiedSemantic => "verified",
41        }
42    }
43
44    /// Multiplier applied to an edge kind's weight in ranking/impact scoring.
45    /// Scope-bound and verified edges count fully; a name-only guess counts
46    /// less so it cannot outrank real relationships.
47    pub fn weight_factor(self) -> f64 {
48        match self {
49            Self::HeuristicStructural => 0.5,
50            Self::ResolvedStructural | Self::VerifiedSemantic => 1.0,
51        }
52    }
53}
54
55/// Which producer derives an edge. Each producer owns only its own
56/// [`Contribution`]; see [`EdgeEvidence::merge`] and [`EdgeEvidence::without`].
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum EvidenceOrigin {
60    /// Graph enrichment (call-graph consolidation + semantic escalation).
61    #[default]
62    Enrichment,
63    /// The `ctx_impact` index builder.
64    ImpactIndex,
65}
66
67/// One producer's evidence for an edge.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Contribution {
70    pub origin: EvidenceOrigin,
71    pub grade: EvidenceGrade,
72    /// Semantic backend identity (`lsp:rust-analyzer@1.0`) for verified edges.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub backend: Option<String>,
75    /// Number of call sites supporting this edge.
76    pub sites: u32,
77}
78
79/// Serialized into `Edge.metadata`. Deliberately small and deterministic: no
80/// timestamps, no source text, no paths beyond the edge's own endpoints.
81///
82/// Several producers can derive the same edge; each keeps its own
83/// contribution, so a recompute or withdrawal by one never erases or
84/// downgrades what another one established.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct EdgeEvidence {
87    /// Schema version of this record.
88    pub v: u8,
89    /// Strongest contribution — what ranking and impact weight by.
90    pub grade: EvidenceGrade,
91    /// Every producer currently deriving the edge, sorted by origin.
92    pub by: Vec<Contribution>,
93}
94
95impl EdgeEvidence {
96    pub const VERSION: u8 = 1;
97
98    /// Evidence from a single producer.
99    pub fn new(
100        grade: EvidenceGrade,
101        origin: EvidenceOrigin,
102        backend: Option<String>,
103        sites: u32,
104    ) -> Self {
105        Self {
106            v: Self::VERSION,
107            grade,
108            by: vec![Contribution {
109                origin,
110                grade,
111                backend,
112                sites,
113            }],
114        }
115    }
116
117    fn from_contributions(mut by: Vec<Contribution>) -> Option<Self> {
118        by.sort_by_key(|c| c.origin);
119        let grade = by.iter().map(|c| c.grade).max()?;
120        Some(Self {
121            v: Self::VERSION,
122            grade,
123            by,
124        })
125    }
126
127    /// The strongest contribution (lowest origin on ties) — the one whose
128    /// backend and site count describe the edge.
129    pub fn primary(&self) -> Option<&Contribution> {
130        self.by
131            .iter()
132            .max_by(|a, b| a.grade.cmp(&b.grade).then(b.origin.cmp(&a.origin)))
133    }
134
135    pub fn has(&self, origin: EvidenceOrigin) -> bool {
136        self.by.iter().any(|c| c.origin == origin)
137    }
138
139    /// `incoming` replaces the contributions of its own origins on top of
140    /// `existing`; every other producer's contribution is kept as is. A
141    /// producer recomputing its edge is authoritative for its own part only.
142    pub fn merge(existing: Option<Self>, incoming: Self) -> Self {
143        let mut by: Vec<Contribution> = existing
144            .map(|e| e.by)
145            .unwrap_or_default()
146            .into_iter()
147            .filter(|c| !incoming.has(c.origin))
148            .collect();
149        by.extend(incoming.by.iter().cloned());
150        // `by` holds at least `incoming`'s contributions, so this is `Some`.
151        Self::from_contributions(by).unwrap_or(incoming)
152    }
153
154    /// Withdraws `origin`'s contribution. `None` when no producer derives the
155    /// edge any more (the edge should be deleted).
156    pub fn without(self, origin: EvidenceOrigin) -> Option<Self> {
157        Self::from_contributions(self.by.into_iter().filter(|c| c.origin != origin).collect())
158    }
159
160    pub fn to_metadata(&self) -> String {
161        serde_json::to_string(self).unwrap_or_default()
162    }
163
164    /// Parses edge metadata written by this module; anything else (legacy
165    /// edges, other producers' metadata) yields `None`.
166    pub fn from_metadata(metadata: Option<&str>) -> Option<Self> {
167        serde_json::from_str::<Self>(metadata?)
168            .ok()
169            .filter(|e| e.v == Self::VERSION)
170    }
171
172    /// Ranking factor for an edge's metadata. Edges without evidence (legacy,
173    /// non-call producers) keep their full kind weight — unchanged behaviour.
174    pub fn weight_factor_of(metadata: Option<&str>) -> f64 {
175        Self::from_metadata(metadata).map_or(1.0, |e| e.grade.weight_factor())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    /// Producers own only their contribution: one recomputing never
184    /// downgrades another's, and withdrawing keeps the edge while anyone
185    /// else still derives it.
186    #[test]
187    fn contributions_are_owned_per_producer() {
188        use EvidenceGrade::{ResolvedStructural, VerifiedSemantic};
189        use EvidenceOrigin::{Enrichment, ImpactIndex};
190        let verified = EdgeEvidence::new(VerifiedSemantic, Enrichment, Some("lsp:ra@1".into()), 1);
191        let structural = |origin| EdgeEvidence::new(ResolvedStructural, origin, None, 1);
192
193        let both = EdgeEvidence::merge(Some(verified.clone()), structural(ImpactIndex));
194        assert_eq!(both.grade, VerifiedSemantic, "no cross-producer downgrade");
195        assert_eq!(
196            both.primary().and_then(|c| c.backend.as_deref()),
197            Some("lsp:ra@1")
198        );
199
200        let recomputed = EdgeEvidence::merge(Some(both.clone()), structural(Enrichment));
201        assert_eq!(
202            recomputed.grade, ResolvedStructural,
203            "owner may weaken its own part"
204        );
205        assert!(recomputed.has(ImpactIndex));
206
207        let kept = both.clone().without(Enrichment).unwrap();
208        assert_eq!(
209            kept.grade, ResolvedStructural,
210            "impact still derives the edge"
211        );
212        assert!(
213            kept.without(ImpactIndex).is_none(),
214            "nobody derives it → delete"
215        );
216        assert_eq!(
217            EdgeEvidence::from_metadata(Some(&both.to_metadata())),
218            Some(both),
219            "deterministic round trip of the stored form"
220        );
221    }
222
223    #[test]
224    fn foreign_or_legacy_metadata_keeps_full_weight() {
225        let heuristic = EdgeEvidence::new(
226            EvidenceGrade::HeuristicStructural,
227            EvidenceOrigin::Enrichment,
228            None,
229            1,
230        );
231        assert_eq!(
232            EdgeEvidence::weight_factor_of(Some(&heuristic.to_metadata())),
233            0.5
234        );
235        assert_eq!(EdgeEvidence::weight_factor_of(None), 1.0);
236        assert_eq!(
237            EdgeEvidence::weight_factor_of(Some("kind=fn;exported")),
238            1.0
239        );
240        assert_eq!(
241            EdgeEvidence::weight_factor_of(Some(
242                r#"{"v":9,"grade":"verified_semantic","sites":1}"#
243            )),
244            1.0,
245            "unknown schema versions are ignored, not misread"
246        );
247    }
248}