use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShadowCompareVerdict {
Same,
Improved,
Regression,
Ambiguous,
Unavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShadowQueryName {
FindDefinition,
FindReferences,
CountUsages,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShadowQueryInput {
pub symbol: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShadowResultSummary {
pub available: bool,
pub match_count: u64,
pub identities: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticShadowCompareReceipt {
pub schema_version: u32,
pub query: ShadowQueryName,
pub input: ShadowQueryInput,
pub old_result: ShadowResultSummary,
pub new_result: ShadowResultSummary,
pub verdict: ShadowCompareVerdict,
pub notes: Vec<String>,
}
impl SemanticShadowCompareReceipt {
pub fn from_summaries(
query: ShadowQueryName,
input: ShadowQueryInput,
old_result: ShadowResultSummary,
new_result: ShadowResultSummary,
notes: Vec<String>,
) -> Self {
let verdict = classify_verdict(&old_result, &new_result);
Self { schema_version: 1, query, input, old_result, new_result, verdict, notes }
}
}
fn classify_verdict(
old_result: &ShadowResultSummary,
new_result: &ShadowResultSummary,
) -> ShadowCompareVerdict {
if !old_result.available || !new_result.available {
return ShadowCompareVerdict::Unavailable;
}
if old_result == new_result {
return ShadowCompareVerdict::Same;
}
if new_result.match_count > old_result.match_count {
return ShadowCompareVerdict::Improved;
}
if new_result.match_count < old_result.match_count {
return ShadowCompareVerdict::Regression;
}
ShadowCompareVerdict::Ambiguous
}
pub fn summarize_identities(identities: Option<Vec<String>>) -> ShadowResultSummary {
match identities {
Some(mut values) => {
values.sort();
values.dedup();
let match_count = u64::try_from(values.len()).unwrap_or(u64::MAX);
ShadowResultSummary { available: true, match_count, identities: values }
}
None => ShadowResultSummary { available: false, match_count: 0, identities: Vec::new() },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn receipt_json_shape_is_stable() -> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::FindReferences,
ShadowQueryInput { symbol: "My::pkg::f".to_string() },
summarize_identities(Some(vec!["b.pm:3:2".to_string(), "a.pm:1:1".to_string()])),
summarize_identities(Some(vec!["a.pm:1:1".to_string(), "c.pm:9:9".to_string()])),
vec!["fixture=h1".to_string()],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
let expected = serde_json::json!({
"schema_version": 1,
"query": "find_references",
"input": {"symbol": "My::pkg::f"},
"old_result": {
"available": true,
"match_count": 2,
"identities": ["a.pm:1:1", "b.pm:3:2"]
},
"new_result": {
"available": true,
"match_count": 2,
"identities": ["a.pm:1:1", "c.pm:9:9"]
},
"verdict": "ambiguous",
"notes": ["fixture=h1"]
});
assert_eq!(got, expected);
Ok(())
}
#[test]
fn unavailable_when_fact_path_missing() {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::FindDefinition,
ShadowQueryInput { symbol: "X::y".to_string() },
summarize_identities(None),
summarize_identities(Some(vec!["x.pm:1:1".to_string()])),
vec!["old path missing fact-backed answer".to_string()],
);
assert_eq!(receipt.verdict, ShadowCompareVerdict::Unavailable);
}
#[test]
fn same_verdict_when_results_identical() {
let summary = summarize_identities(Some(vec!["a.pm:1:1".to_string()]));
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::FindDefinition,
ShadowQueryInput { symbol: "Foo::bar".to_string() },
summary.clone(),
summary,
vec![],
);
assert_eq!(receipt.verdict, ShadowCompareVerdict::Same);
}
#[test]
fn improved_verdict_when_new_has_more_matches() {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::FindReferences,
ShadowQueryInput { symbol: "Foo::bar".to_string() },
summarize_identities(Some(vec!["a.pm:1:1".to_string()])),
summarize_identities(Some(vec!["a.pm:1:1".to_string(), "b.pm:2:2".to_string()])),
vec![],
);
assert_eq!(receipt.verdict, ShadowCompareVerdict::Improved);
}
#[test]
fn regression_verdict_when_new_has_fewer_matches() {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::FindReferences,
ShadowQueryInput { symbol: "Foo::bar".to_string() },
summarize_identities(Some(vec!["a.pm:1:1".to_string(), "b.pm:2:2".to_string()])),
summarize_identities(Some(vec!["a.pm:1:1".to_string()])),
vec![],
);
assert_eq!(receipt.verdict, ShadowCompareVerdict::Regression);
}
#[test]
fn count_usages_improved_with_empty_identities() {
let old_summary =
ShadowResultSummary { available: true, match_count: 3, identities: vec![] };
let new_summary =
ShadowResultSummary { available: true, match_count: 5, identities: vec![] };
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::CountUsages,
ShadowQueryInput { symbol: "Foo::bar".to_string() },
old_summary,
new_summary,
vec![],
);
assert_eq!(receipt.verdict, ShadowCompareVerdict::Improved);
}
#[test]
fn count_usages_regression_with_empty_identities() {
let old_summary =
ShadowResultSummary { available: true, match_count: 5, identities: vec![] };
let new_summary =
ShadowResultSummary { available: true, match_count: 2, identities: vec![] };
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::CountUsages,
ShadowQueryInput { symbol: "Foo::bar".to_string() },
old_summary,
new_summary,
vec![],
);
assert_eq!(receipt.verdict, ShadowCompareVerdict::Regression);
}
#[test]
fn both_unavailable_yields_unavailable() {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::FindDefinition,
ShadowQueryInput { symbol: "Foo::bar".to_string() },
summarize_identities(None),
summarize_identities(None),
vec![],
);
assert_eq!(receipt.verdict, ShadowCompareVerdict::Unavailable);
}
#[test]
fn summarize_identities_sorts_and_deduplicates() {
let summary = summarize_identities(Some(vec![
"c.pm:3:1".to_string(),
"a.pm:1:1".to_string(),
"a.pm:1:1".to_string(),
"b.pm:2:2".to_string(),
]));
assert_eq!(summary.available, true);
assert_eq!(summary.match_count, 3);
assert_eq!(
summary.identities,
vec!["a.pm:1:1".to_string(), "b.pm:2:2".to_string(), "c.pm:3:1".to_string()]
);
}
}