use crate::functional_spec::SpecProvenance;
use crate::spec_check::SpecCheckResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase")]
pub enum SpecSection {
Entities,
Operations,
UiStates,
Navigation,
Auth,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProvenanceMix {
#[serde(default)]
pub observed: u32,
#[serde(default)]
pub inferred: u32,
#[serde(default)]
pub assumed: u32,
}
impl ProvenanceMix {
pub fn denominator(&self) -> u32 {
self.observed + self.inferred
}
pub fn add(&mut self, p: SpecProvenance) {
match p {
SpecProvenance::Observed => self.observed += 1,
SpecProvenance::Inferred => self.inferred += 1,
SpecProvenance::Assumed => self.assumed += 1,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum GapReason {
NotGenerated,
BehaviorMismatch,
Unverifiable,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CoverageGap {
#[serde(rename = "ref")]
pub r#ref: String,
pub section: SpecSection,
pub node_provenance: SpecProvenance,
pub reason: GapReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SectionVerdict {
pub section: SpecSection,
pub coverage: f64,
#[serde(default)]
pub assumed_fill_rate: f64,
pub provenance_mix: ProvenanceMix,
#[serde(default)]
pub credibility: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staleness_seconds: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CompletenessVerdict {
pub spec_version: String,
pub coverage: f64,
#[serde(default)]
pub assumed_fill_rate: f64,
pub provenance_mix: ProvenanceMix,
#[serde(default)]
pub credibility: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staleness_seconds: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sections: Vec<SectionVerdict>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gaps: Vec<CoverageGap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_states_spec_check: Option<SpecCheckResult>,
pub evaluated_at: String,
}
impl CompletenessVerdict {
pub fn coverage_from(mix: &ProvenanceMix, observed_inferred_gaps: u32) -> f64 {
let denom = mix.denominator();
if denom == 0 {
return 1.0;
}
let numerator = denom.saturating_sub(observed_inferred_gaps);
f64::from(numerator) / f64::from(denom)
}
pub fn counted_gaps(&self) -> u32 {
self.gaps
.iter()
.filter(|g| g.node_provenance != SpecProvenance::Assumed)
.count() as u32
}
pub fn coverage_is_consistent(&self) -> bool {
let expected = Self::coverage_from(&self.provenance_mix, self.counted_gaps());
(self.coverage - expected).abs() < 1e-9
}
}
#[cfg(test)]
mod tests {
use super::*;
fn verdict(mix: ProvenanceMix, gaps: Vec<CoverageGap>) -> CompletenessVerdict {
let counted = gaps
.iter()
.filter(|g| g.node_provenance != SpecProvenance::Assumed)
.count() as u32;
CompletenessVerdict {
spec_version: "0".into(),
coverage: CompletenessVerdict::coverage_from(&mix, counted),
assumed_fill_rate: 0.0,
provenance_mix: mix,
credibility: 0.0,
staleness_seconds: None,
sections: vec![],
gaps,
ui_states_spec_check: None,
evaluated_at: "2026-06-13T00:00:00Z".into(),
}
}
fn gap(p: SpecProvenance) -> CoverageGap {
CoverageGap {
r#ref: "entities.Invoice".into(),
section: SpecSection::Entities,
node_provenance: p,
reason: GapReason::NotGenerated,
detail: None,
}
}
#[test]
fn coverage_denominator_excludes_assumed() {
let mix = ProvenanceMix {
observed: 6,
inferred: 2,
assumed: 4,
};
assert_eq!(mix.denominator(), 8);
assert!((CompletenessVerdict::coverage_from(&mix, 2) - 0.75).abs() < 1e-12);
}
#[test]
fn assumed_nodes_never_count_against_coverage() {
let mix = ProvenanceMix {
observed: 4,
inferred: 0,
assumed: 10,
};
let v = verdict(mix, vec![gap(SpecProvenance::Assumed)]);
assert_eq!(v.counted_gaps(), 0);
assert!(
(v.coverage - 1.0).abs() < 1e-12,
"all observed covered → 1.0"
);
assert!(v.coverage_is_consistent());
}
#[test]
fn empty_observable_spec_is_vacuously_complete() {
let mix = ProvenanceMix {
observed: 0,
inferred: 0,
assumed: 3,
};
assert_eq!(CompletenessVerdict::coverage_from(&mix, 0), 1.0);
}
#[test]
fn consistency_check_catches_wrong_coverage() {
let mut v = verdict(
ProvenanceMix {
observed: 4,
inferred: 0,
assumed: 0,
},
vec![gap(SpecProvenance::Observed)],
);
assert!(v.coverage_is_consistent());
v.coverage = 0.99; assert!(!v.coverage_is_consistent());
}
#[test]
fn verdict_round_trips_with_embedded_spec_check_absent() {
let v = verdict(
ProvenanceMix {
observed: 1,
inferred: 1,
assumed: 0,
},
vec![],
);
let json = serde_json::to_string(&v).unwrap();
assert!(
!json.contains("uiStatesSpecCheck"),
"absent embed must skip-serialize"
);
let round: CompletenessVerdict = serde_json::from_str(&json).unwrap();
assert_eq!(
serde_json::to_value(&round).unwrap(),
serde_json::to_value(&v).unwrap()
);
}
#[test]
fn section_enum_camel_case() {
assert_eq!(
serde_json::to_string(&SpecSection::UiStates).unwrap(),
"\"uiStates\""
);
assert_eq!(
serde_json::to_string(&SpecSection::Entities).unwrap(),
"\"entities\""
);
}
}