1use serde::{Deserialize, Serialize};
9
10use crate::core::call_graph::ScopeMatch;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum EvidenceGrade {
16 HeuristicStructural,
19 ResolvedStructural,
22 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum EvidenceOrigin {
60 #[default]
62 Enrichment,
63 ImpactIndex,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Contribution {
70 pub origin: EvidenceOrigin,
71 pub grade: EvidenceGrade,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub backend: Option<String>,
75 pub sites: u32,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct EdgeEvidence {
87 pub v: u8,
89 pub grade: EvidenceGrade,
91 pub by: Vec<Contribution>,
93}
94
95impl EdgeEvidence {
96 pub const VERSION: u8 = 1;
97
98 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 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 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 Self::from_contributions(by).unwrap_or(incoming)
152 }
153
154 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 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 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 #[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}