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,
VisibleSymbols,
MethodCandidates,
SymbolAt,
RenamePlan,
SafeDeletePlan,
CompletionVisibility,
DiagnosticsCheck,
Hover,
}
#[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::*;
use proptest::prelude::*;
use proptest::test_runner::Config as ProptestConfig;
#[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!(summary.available);
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()]
);
}
#[test]
fn shadow_query_name_json_round_trip() -> Result<(), Box<dyn std::error::Error>> {
let variants = [
(ShadowQueryName::FindDefinition, "\"find_definition\""),
(ShadowQueryName::FindReferences, "\"find_references\""),
(ShadowQueryName::CountUsages, "\"count_usages\""),
(ShadowQueryName::VisibleSymbols, "\"visible_symbols\""),
(ShadowQueryName::MethodCandidates, "\"method_candidates\""),
(ShadowQueryName::SymbolAt, "\"symbol_at\""),
(ShadowQueryName::RenamePlan, "\"rename_plan\""),
(ShadowQueryName::SafeDeletePlan, "\"safe_delete_plan\""),
(ShadowQueryName::CompletionVisibility, "\"completion_visibility\""),
(ShadowQueryName::DiagnosticsCheck, "\"diagnostics_check\""),
];
for (variant, expected_json) in variants {
let json = serde_json::to_string(&variant)?;
assert_eq!(json, expected_json, "serialization mismatch for {variant:?}");
let deserialized: ShadowQueryName = serde_json::from_str(&json)?;
assert_eq!(deserialized, variant, "round-trip mismatch for {variant:?}");
}
Ok(())
}
#[test]
fn receipt_json_shape_stable_for_visible_symbols() -> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::VisibleSymbols,
ShadowQueryInput { symbol: "my_func".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![],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
let expected = serde_json::json!({
"schema_version": 1,
"query": "visible_symbols",
"input": {"symbol": "my_func"},
"old_result": {
"available": true,
"match_count": 1,
"identities": ["a.pm:1:1"]
},
"new_result": {
"available": true,
"match_count": 2,
"identities": ["a.pm:1:1", "b.pm:2:2"]
},
"verdict": "improved",
"notes": []
});
assert_eq!(got, expected);
Ok(())
}
#[test]
fn receipt_json_shape_stable_for_method_candidates() -> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::MethodCandidates,
ShadowQueryInput { symbol: "new".to_string() },
summarize_identities(Some(vec!["Foo.pm:10:5".to_string()])),
summarize_identities(Some(vec!["Foo.pm:10:5".to_string()])),
vec![],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
assert_eq!(got["query"], "method_candidates");
assert_eq!(receipt.verdict, ShadowCompareVerdict::Same);
Ok(())
}
#[test]
fn receipt_json_shape_stable_for_symbol_at() -> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::SymbolAt,
ShadowQueryInput { symbol: "$var".to_string() },
summarize_identities(None),
summarize_identities(Some(vec!["main.pm:5:3".to_string()])),
vec!["legacy path unavailable".to_string()],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
assert_eq!(got["query"], "symbol_at");
assert_eq!(receipt.verdict, ShadowCompareVerdict::Unavailable);
Ok(())
}
#[test]
fn receipt_json_shape_stable_for_rename_plan() -> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::RenamePlan,
ShadowQueryInput { symbol: "old_name".to_string() },
summarize_identities(Some(vec!["a.pm:1:1".to_string()])),
summarize_identities(Some(vec!["a.pm:1:1".to_string()])),
vec![],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
assert_eq!(got["query"], "rename_plan");
assert_eq!(receipt.verdict, ShadowCompareVerdict::Same);
Ok(())
}
#[test]
fn receipt_json_shape_stable_for_safe_delete_plan() -> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::SafeDeletePlan,
ShadowQueryInput { symbol: "unused_sub".to_string() },
summarize_identities(Some(vec![])),
summarize_identities(Some(vec![])),
vec![],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
assert_eq!(got["query"], "safe_delete_plan");
assert_eq!(receipt.verdict, ShadowCompareVerdict::Same);
Ok(())
}
#[test]
fn receipt_json_shape_stable_for_completion_visibility()
-> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::CompletionVisibility,
ShadowQueryInput { symbol: "use Foo".to_string() },
summarize_identities(Some(vec!["bar".to_string(), "baz".to_string()])),
summarize_identities(Some(vec![
"bar".to_string(),
"baz".to_string(),
"qux".to_string(),
])),
vec![],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
assert_eq!(got["query"], "completion_visibility");
assert_eq!(receipt.verdict, ShadowCompareVerdict::Improved);
Ok(())
}
#[test]
fn receipt_json_shape_stable_for_diagnostics_check() -> Result<(), Box<dyn std::error::Error>> {
let receipt = SemanticShadowCompareReceipt::from_summaries(
ShadowQueryName::DiagnosticsCheck,
ShadowQueryInput { symbol: "undef_sym".to_string() },
summarize_identities(Some(vec!["warn:a.pm:3:1".to_string()])),
summarize_identities(Some(vec![])),
vec!["false positive suppressed".to_string()],
);
let got: serde_json::Value = serde_json::to_value(&receipt)?;
assert_eq!(got["query"], "diagnostics_check");
assert_eq!(receipt.verdict, ShadowCompareVerdict::Regression);
Ok(())
}
fn arb_shadow_result_summary() -> impl Strategy<Value = ShadowResultSummary> {
(any::<bool>(), 0u64..256, prop::collection::vec("[a-z0-9_.:/]{1,20}", 0..16)).prop_map(
|(available, match_count, mut identities)| {
identities.sort();
identities.dedup();
ShadowResultSummary { available, match_count, identities }
},
)
}
proptest! {
#![proptest_config(ProptestConfig {
failure_persistence: None,
..ProptestConfig::default()
})]
#[test]
fn prop_shadow_compare_verdict_determinism(
old_summary in arb_shadow_result_summary(),
new_summary in arb_shadow_result_summary(),
) {
let verdict_a = classify_verdict(&old_summary, &new_summary);
let verdict_b = classify_verdict(&old_summary, &new_summary);
prop_assert_eq!(
verdict_a, verdict_b,
"classify_verdict must be deterministic for the same inputs"
);
if !old_summary.available || !new_summary.available {
prop_assert_eq!(
verdict_a,
ShadowCompareVerdict::Unavailable,
"verdict must be Unavailable when either path is unavailable"
);
} else if old_summary == new_summary {
prop_assert_eq!(
verdict_a,
ShadowCompareVerdict::Same,
"verdict must be Same when summaries are equal"
);
} else if new_summary.match_count > old_summary.match_count {
prop_assert_eq!(
verdict_a,
ShadowCompareVerdict::Improved,
"verdict must be Improved when new path has more matches"
);
} else if new_summary.match_count < old_summary.match_count {
prop_assert_eq!(
verdict_a,
ShadowCompareVerdict::Regression,
"verdict must be Regression when new path has fewer matches"
);
} else {
prop_assert_eq!(
verdict_a,
ShadowCompareVerdict::Ambiguous,
"verdict must be Ambiguous when counts are equal but content differs"
);
}
}
}
}