use serde::{Deserialize, Serialize};
pub const HIR_SCHEMA_VERSION: &str = "hir.v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HirSummary {
pub item_count: usize,
pub item_kind_sequence: Vec<String>,
pub scope_count: usize,
pub binding_count: usize,
pub package_count: usize,
pub slot_count: usize,
pub directive_count: usize,
pub module_request_count: usize,
pub dynamic_boundary_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotEntry {
pub fixture_id: String,
pub source_hash: String,
pub hir_schema_version: String,
pub hir_summary: HirSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotManifest {
pub schema: String,
pub kpi: String,
pub claim_boundary: String,
pub hir_schema_version: String,
pub generated_on: String,
pub entries: Vec<SnapshotEntry>,
}
impl SnapshotManifest {
pub fn new(generated_on: String) -> Self {
Self {
schema: "semantic_snapshot.v1".to_string(),
kpi: "semantic_snapshot_stability_rate".to_string(),
claim_boundary: concat!(
"Snapshot proves deterministic HIR stability only. ",
"This is NOT curated-gold correctness. ",
"Curated gold (independent human labeling) is a separate, future schema.",
)
.to_string(),
hir_schema_version: HIR_SCHEMA_VERSION.to_string(),
generated_on,
entries: Vec::new(),
}
}
pub fn stability_rate(&self, fresh_entries: &[SnapshotEntry]) -> (usize, usize, f64) {
let total = self.entries.len();
if total == 0 {
return (0, 0, 1.0);
}
let stable = self
.entries
.iter()
.filter(|recorded| {
fresh_entries.iter().any(|fresh| {
fresh.fixture_id == recorded.fixture_id
&& fresh.source_hash == recorded.source_hash
&& fresh.hir_schema_version == recorded.hir_schema_version
&& fresh.hir_summary == recorded.hir_summary
})
})
.count();
let rate = stable as f64 / total as f64;
(stable, total, rate)
}
}
pub fn source_hash(source: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h1 = DefaultHasher::new();
source.hash(&mut h1);
let d1 = h1.finish();
let mut h2 = DefaultHasher::new();
0x9e3779b97f4a7c15u64.hash(&mut h2);
source.hash(&mut h2);
let d2 = h2.finish();
format!("{d1:016x}{d2:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_hash_is_deterministic() {
let s = "package Foo; sub bar { return 1; } 1;";
assert_eq!(source_hash(s), source_hash(s));
}
#[test]
fn source_hash_differs_for_different_sources() {
let a = source_hash("my $x = 1;");
let b = source_hash("my $y = 2;");
assert_ne!(a, b, "distinct sources must produce distinct hashes");
}
#[test]
fn source_hash_len_is_32() {
assert_eq!(source_hash("hello").len(), 32);
}
#[test]
fn manifest_schema_discriminators() {
let m = SnapshotManifest::new("2026-06-21".to_string());
assert_eq!(m.schema, "semantic_snapshot.v1");
assert_eq!(m.kpi, "semantic_snapshot_stability_rate");
assert_eq!(m.hir_schema_version, HIR_SCHEMA_VERSION);
assert!(m.claim_boundary.contains("stability"), "claim boundary must mention stability");
assert!(m.claim_boundary.contains("NOT"), "claim boundary must disclaim gold/correctness");
}
#[test]
fn stability_rate_empty_manifest() {
let m = SnapshotManifest::new("2026-06-21".to_string());
let (stable, total, rate) = m.stability_rate(&[]);
assert_eq!(stable, 0);
assert_eq!(total, 0);
assert!((rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn stability_rate_all_match() {
let entry = SnapshotEntry {
fixture_id: "foo".to_string(),
source_hash: "abc".to_string(),
hir_schema_version: HIR_SCHEMA_VERSION.to_string(),
hir_summary: HirSummary {
item_count: 2,
item_kind_sequence: vec!["SubDecl".to_string(), "LiteralExpr".to_string()],
scope_count: 1,
binding_count: 0,
package_count: 0,
slot_count: 0,
directive_count: 0,
module_request_count: 0,
dynamic_boundary_count: 0,
},
};
let mut m = SnapshotManifest::new("2026-06-21".to_string());
m.entries.push(entry.clone());
let (stable, total, rate) = m.stability_rate(&[entry]);
assert_eq!(stable, 1);
assert_eq!(total, 1);
assert!((rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn stability_rate_mismatch_on_drift() {
let recorded = SnapshotEntry {
fixture_id: "foo".to_string(),
source_hash: "abc".to_string(),
hir_schema_version: HIR_SCHEMA_VERSION.to_string(),
hir_summary: HirSummary {
item_count: 2,
item_kind_sequence: vec!["SubDecl".to_string()],
scope_count: 1,
binding_count: 0,
package_count: 0,
slot_count: 0,
directive_count: 0,
module_request_count: 0,
dynamic_boundary_count: 0,
},
};
let drifted = SnapshotEntry {
hir_summary: HirSummary {
item_count: 5, item_kind_sequence: vec!["SubDecl".to_string(), "PackageDecl".to_string()],
scope_count: 2,
..recorded.hir_summary.clone()
},
..recorded.clone()
};
let mut m = SnapshotManifest::new("2026-06-21".to_string());
m.entries.push(recorded);
let (stable, total, rate) = m.stability_rate(&[drifted]);
assert_eq!(stable, 0);
assert_eq!(total, 1);
assert!((rate - 0.0).abs() < f64::EPSILON);
}
#[test]
fn snapshot_entry_round_trips_json() {
let entry = SnapshotEntry {
fixture_id: "sub_basic".to_string(),
source_hash: source_hash("package Foo; sub bar { 1; } 1;"),
hir_schema_version: HIR_SCHEMA_VERSION.to_string(),
hir_summary: HirSummary {
item_count: 3,
item_kind_sequence: vec![
"PackageDecl".to_string(),
"SubDecl".to_string(),
"LiteralExpr".to_string(),
],
scope_count: 2,
binding_count: 0,
package_count: 1,
slot_count: 1,
directive_count: 0,
module_request_count: 0,
dynamic_boundary_count: 0,
},
};
let json = serde_json::to_string(&entry).expect("serialize");
let back: SnapshotEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, entry);
}
}