use serde_json::{Map, Value};
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub enum ProvenanceClass {
Input,
ModelledInput,
Computed,
ClosedForm,
Spec,
Published,
Constant,
Modelled,
InternalConsistency,
Measured,
MeasuredOrInput,
Derived,
}
impl ProvenanceClass {
pub const ALL: &'static [ProvenanceClass] = &[
ProvenanceClass::Input,
ProvenanceClass::ModelledInput,
ProvenanceClass::Computed,
ProvenanceClass::ClosedForm,
ProvenanceClass::Spec,
ProvenanceClass::Published,
ProvenanceClass::Constant,
ProvenanceClass::Modelled,
ProvenanceClass::InternalConsistency,
ProvenanceClass::Measured,
ProvenanceClass::MeasuredOrInput,
ProvenanceClass::Derived,
];
pub fn as_str(&self) -> &'static str {
match self {
ProvenanceClass::Input => "input",
ProvenanceClass::ModelledInput => "modelled-input",
ProvenanceClass::Computed => "computed",
ProvenanceClass::ClosedForm => "closed-form",
ProvenanceClass::Spec => "spec",
ProvenanceClass::Published => "published",
ProvenanceClass::Constant => "constant",
ProvenanceClass::Modelled => "modelled",
ProvenanceClass::InternalConsistency => "internal-consistency",
ProvenanceClass::Measured => "measured",
ProvenanceClass::MeasuredOrInput => "measured-or-input",
ProvenanceClass::Derived => "derived",
}
}
pub fn parse(s: &str) -> Option<ProvenanceClass> {
ProvenanceClass::ALL
.iter()
.copied()
.find(|c| c.as_str() == s)
}
pub fn evidence_tier(&self) -> EvidenceTier {
match self {
ProvenanceClass::Published
| ProvenanceClass::Spec
| ProvenanceClass::ClosedForm
| ProvenanceClass::Constant
| ProvenanceClass::Measured
| ProvenanceClass::InternalConsistency => EvidenceTier::Validated,
ProvenanceClass::Modelled | ProvenanceClass::ModelledInput => EvidenceTier::Modelled,
ProvenanceClass::Input => EvidenceTier::Illustrative,
ProvenanceClass::Computed | ProvenanceClass::Derived => {
EvidenceTier::InheritsScenarioLabel
}
ProvenanceClass::MeasuredOrInput => EvidenceTier::DependsOnInput,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub enum EvidenceTier {
Validated,
Modelled,
Illustrative,
InheritsScenarioLabel,
DependsOnInput,
}
impl EvidenceTier {
pub fn as_str(&self) -> &'static str {
match self {
EvidenceTier::Validated => "validated",
EvidenceTier::Modelled => "modelled",
EvidenceTier::Illustrative => "illustrative",
EvidenceTier::InheritsScenarioLabel => "inherits-scenario-label",
EvidenceTier::DependsOnInput => "depends-on-input",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct FieldUnit {
pub path: &'static str,
pub unit: &'static str,
pub provenance: ProvenanceClass,
pub definition: &'static str,
}
pub fn units_block(fields: &[FieldUnit]) -> Value {
let mut m = Map::with_capacity(fields.len());
for f in fields {
let mut e = Map::new();
e.insert("unit".to_string(), Value::from(f.unit));
e.insert("provenance".to_string(), Value::from(f.provenance.as_str()));
if !f.definition.is_empty() {
e.insert("note".to_string(), Value::from(f.definition));
}
m.insert(f.path.to_string(), Value::Object(e));
}
Value::Object(m)
}
pub fn numeric_leaf_paths(doc: &Value) -> Vec<String> {
let mut out = Vec::new();
walk(doc, "", true, &mut out);
out.sort();
out.dedup();
out
}
fn walk(v: &Value, prefix: &str, at_root: bool, out: &mut Vec<String>) {
match v {
Value::Number(_) => out.push(prefix.to_string()),
Value::Array(a) => {
let p = format!("{prefix}[]");
for e in a {
walk(e, &p, false, out);
}
}
Value::Object(m) => {
for (k, val) in m {
if at_root && k == "units" {
continue;
}
let p = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
walk(val, &p, false, out);
}
}
_ => {}
}
}
pub fn is_legal_pattern(key: &str) -> bool {
let segs: Vec<&str> = key.split('.').collect();
if segs.is_empty() || segs.iter().any(|s| s.is_empty()) {
return false;
}
let wild = |s: &str| s == "*" || s == "*[]";
!wild(segs[0]) && !wild(segs[segs.len() - 1])
}
pub fn pattern_matches(key: &str, path: &str) -> bool {
let k: Vec<&str> = key.split('.').collect();
let p: Vec<&str> = path.split('.').collect();
if k.len() != p.len() {
return false;
}
k.iter()
.zip(p.iter())
.all(|(ks, ps)| *ks == "*" || *ks == "*[]" || ks == ps || *ks == ps.trim_end_matches("[]"))
}
pub fn lookup<'a>(units: &'a Map<String, Value>, path: &str) -> Option<(&'a str, &'a Value)> {
if let Some((k, v)) = units.get_key_value(path) {
return Some((k.as_str(), v));
}
units
.iter()
.find(|(k, _)| is_legal_pattern(k) && pattern_matches(k, path))
.map(|(k, v)| (k.as_str(), v))
}
#[derive(Clone, Debug)]
pub struct AuditedField {
pub path: String,
pub matched_by: String,
pub unit: String,
pub provenance: ProvenanceClass,
pub definition: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MalformedEntry {
pub key: String,
pub reason: String,
}
#[derive(Clone, Debug, Default)]
pub struct DocumentAudit {
pub covered: Vec<AuditedField>,
pub missing: Vec<String>,
pub malformed: Vec<MalformedEntry>,
}
impl DocumentAudit {
pub fn field_count(&self) -> usize {
self.covered.len() + self.missing.len()
}
pub fn is_complete(&self) -> bool {
self.missing.is_empty() && self.malformed.is_empty()
}
}
pub fn audit_document(doc: &Value) -> DocumentAudit {
let empty = Map::new();
let units = doc
.get("units")
.and_then(|u| u.as_object())
.unwrap_or(&empty);
let mut audit = DocumentAudit::default();
for (key, entry) in units {
if !is_legal_pattern(key) {
audit.malformed.push(MalformedEntry {
key: key.clone(),
reason: "not a legal path pattern (empty segment, or `*` first or last)"
.to_string(),
});
continue;
}
match entry_fields(entry) {
Ok(_) => {}
Err(reason) => audit.malformed.push(MalformedEntry {
key: key.clone(),
reason,
}),
}
}
for path in numeric_leaf_paths(doc) {
match lookup(units, &path) {
Some((key, entry)) => match entry_fields(entry) {
Ok((unit, provenance, definition)) => audit.covered.push(AuditedField {
path,
matched_by: key.to_string(),
unit: unit.to_string(),
provenance,
definition: definition.map(str::to_string),
}),
Err(_) => audit.missing.push(path),
},
None => audit.missing.push(path),
}
}
audit
}
fn entry_fields(entry: &Value) -> Result<(&str, ProvenanceClass, Option<&str>), String> {
let obj = entry
.as_object()
.ok_or_else(|| "entry is not an object".to_string())?;
let unit = obj
.get("unit")
.and_then(|u| u.as_str())
.ok_or_else(|| "no `unit` string".to_string())?;
if unit.is_empty() {
return Err("`unit` is the empty string".to_string());
}
let prov_str = obj
.get("provenance")
.and_then(|p| p.as_str())
.ok_or_else(|| "no `provenance` string".to_string())?;
let provenance = ProvenanceClass::parse(prov_str)
.ok_or_else(|| format!("`provenance` {prov_str:?} is outside the vocabulary"))?;
let note = obj.get("note").and_then(|n| n.as_str());
Ok((unit, provenance, note))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_provenance_vocabulary_round_trips() {
for c in ProvenanceClass::ALL {
assert_eq!(ProvenanceClass::parse(c.as_str()), Some(*c));
}
assert_eq!(ProvenanceClass::parse("guessed"), None);
assert_eq!(ProvenanceClass::parse(""), None);
}
#[test]
fn every_provenance_class_states_an_evidence_tier() {
for c in ProvenanceClass::ALL {
let t = c.evidence_tier();
if matches!(c, ProvenanceClass::Computed | ProvenanceClass::Derived) {
assert_eq!(t, EvidenceTier::InheritsScenarioLabel, "{c:?}");
} else {
assert_ne!(t, EvidenceTier::InheritsScenarioLabel, "{c:?}");
}
}
let spellings: Vec<&str> = ProvenanceClass::ALL.iter().map(|c| c.as_str()).collect();
let mut sorted = spellings.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
spellings.len(),
"duplicate provenance spelling"
);
}
#[test]
fn arrays_contribute_one_suffixed_segment_shared_by_every_row() {
let doc = json!({
"a": 1,
"rows": [ {"x": 1.0, "y": 2}, {"x": 3.0, "y": 4} ],
"nested": {"deep": [[1, 2], [3]]},
"text": "not a number",
"units": {"a": {"unit": "m", "provenance": "input"}},
});
assert_eq!(
numeric_leaf_paths(&doc),
vec![
"a".to_string(),
"nested.deep[][]".to_string(),
"rows[].x".to_string(),
"rows[].y".to_string(),
]
);
}
#[test]
fn both_array_spellings_and_the_wildcard_resolve_to_the_same_leaf() {
assert!(pattern_matches("rows[].x", "rows[].x"));
assert!(pattern_matches("rows.x", "rows[].x"));
assert!(pattern_matches("t.rows[].*.rms", "t.rows[].ut1.rms"));
assert!(!pattern_matches("rows[].x", "rows[].y"));
assert!(!pattern_matches("rows[].x", "other[].x"));
assert!(!pattern_matches("rows[].x", "a.rows[].x"));
}
#[test]
fn a_wildcard_may_not_stand_alone_or_swallow_the_field_name() {
assert!(is_legal_pattern("a.*.b"));
assert!(!is_legal_pattern("*"));
assert!(!is_legal_pattern("*.b"));
assert!(!is_legal_pattern("a.*"));
assert!(!is_legal_pattern("a..b"));
let doc = json!({"a": {"b": 1}, "units": {"*.b": {"unit": "m", "provenance": "input"}}});
let audit = audit_document(&doc);
assert_eq!(audit.missing, vec!["a.b".to_string()]);
assert_eq!(audit.malformed.len(), 1);
}
#[test]
fn a_placeholder_entry_does_not_buy_coverage() {
for bad in [
json!({"provenance": "input"}),
json!({"unit": "m"}),
json!({"unit": "", "provenance": "input"}),
json!({"unit": "m", "provenance": "vibes"}),
json!("m"),
] {
let doc = json!({"a": 1.0, "units": {"a": bad}});
let audit = audit_document(&doc);
assert_eq!(audit.missing, vec!["a".to_string()], "{doc}");
assert_eq!(audit.malformed.len(), 1, "{doc}");
assert!(!audit.is_complete());
}
}
#[test]
fn a_complete_block_covers_every_leaf_and_carries_the_definitions() {
let doc = json!({
"a": 1.0,
"rows": [{"x": 2.0}],
"units": {
"a": {"unit": "m", "provenance": "input", "note": "the a"},
"rows[].x": {"unit": "s", "provenance": "computed"},
},
});
let audit = audit_document(&doc);
assert!(audit.is_complete(), "{:?}", audit);
assert_eq!(audit.field_count(), 2);
assert_eq!(audit.covered[0].definition.as_deref(), Some("the a"));
assert_eq!(audit.covered[1].definition, None);
assert_eq!(audit.covered[1].provenance, ProvenanceClass::Computed);
}
#[test]
fn the_renderer_emits_the_shape_the_audit_reads() {
let block = units_block(&[
FieldUnit {
path: "a",
unit: "m",
provenance: ProvenanceClass::Input,
definition: "the a",
},
FieldUnit {
path: "b",
unit: "s",
provenance: ProvenanceClass::Computed,
definition: "",
},
]);
assert_eq!(block["a"]["unit"], "m");
assert_eq!(block["a"]["provenance"], "input");
assert_eq!(block["a"]["note"], "the a");
assert!(block["b"].get("note").is_none());
let doc = json!({"a": 1.0, "b": 2.0, "units": block});
assert!(audit_document(&doc).is_complete());
}
}