///|
/// Exact metadata equality within filename-entity groups, without a fixed
/// expected value. Missing grouping entities form an explicit null group.
pub struct ConsistencyRules {
priv group_by : Array[String]
priv fields : Array[String]
priv require_present : Bool
}
///|
pub fn ConsistencyRules::new(
group_by : Array[String],
fields : Array[String],
require_present : Bool,
) -> ConsistencyRules raise TraceError {
let group_by = review_fields(group_by)
let fields = review_fields(fields)
if fields.is_empty() {
reject(
"empty_consistency_fields", "", "Specify at least one metadata field.",
)
}
for key in group_by {
if ![
"sub", "ses", "task", "acq", "ce", "rec", "dir", "run", "echo", "part", "chunk",
].contains(key) {
reject(
"invalid_group_entity", key, "Group by supported filename entities only; select a suffix scope before auditing mixed modalities.",
)
}
}
group_by.sort_by(lexical_compare)
fields.sort_by(lexical_compare)
let rules : ConsistencyRules = { group_by, fields, require_present, }
ignore(parse_source("", rules.to_json(), container_limit=2))
rules
}
///|
fn ConsistencyRules::rules_value(self : ConsistencyRules) -> Json {
Json::object({
"group_by": Json::array(self.group_by.map(Json::string)),
"fields": Json::array(self.fields.map(Json::string)),
"require_present": Json::boolean(self.require_present),
})
}
///|
pub fn ConsistencyRules::to_json(self : ConsistencyRules) -> String {
canonical_json(self.rules_value())
}
///|
pub fn ConsistencyRules::from_json(
text : String,
) -> ConsistencyRules raise TraceError {
guard parse_source("", text, container_limit=2) is Object(input) else {
reject(
"invalid_consistency_rules", "", "Expected group_by, fields and require_present.",
)
abort("reject raises")
}
if sorted_keys(input) != ["fields", "group_by", "require_present"] {
reject(
"invalid_consistency_rules", "", "Exactly fields, group_by and require_present must be supplied.",
)
}
guard input["fields"] is Array(fields) && input["group_by"] is Array(groups) else {
reject(
"invalid_consistency_rules", "", "fields and group_by must be string arrays.",
)
abort("reject raises")
}
let strings = fn(values : Array[Json]) -> Array[String] raise TraceError {
values.map(value => {
match value {
String(s) => s
_ => {
reject(
"invalid_consistency_rules", "", "Array entries must be strings.",
)
abort("reject raises")
}
}
})
}
let present = match input["require_present"] {
True => true
False => false
_ => {
reject(
"invalid_consistency_rules", "", "require_present must be boolean.",
)
abort("reject raises")
}
}
ConsistencyRules::new(strings(groups), strings(fields), present)
}
///|
pub struct ConsistencyReport {
priv decision : ExpectationDecision
priv violations : Int32
priv value : Json
}
///|
pub fn ConsistencyReport::decision(
self : ConsistencyReport,
) -> ExpectationDecision {
self.decision
}
///|
pub fn ConsistencyReport::violation_count(self : ConsistencyReport) -> Int32 {
self.violations
}
///|
pub fn ConsistencyReport::to_json(self : ConsistencyReport) -> String {
canonical_json(self.value)
}
///|
pub fn AuditReport::check_consistency(
self : AuditReport,
rules : ConsistencyRules,
) -> ConsistencyReport {
let groups : Map[String, Array[Int32]] = Map([])
let keys : Map[String, Json] = Map([])
for i, entry in self.entries {
if entry.result is Some(_) {
let selected = try! selector(entry.path, sidecar=false)
let key : Map[String, Json] = Map([])
for entity in rules.group_by {
key[entity] = match selected.entities.get(entity) {
Some(v) => Json::string(v)
None => Json::null()
}
}
let value = Json::object(key)
let token = canonical_json(value)
keys[token] = value
match groups.get(token) {
Some(indices) => indices.push(i)
None => groups[token] = [i]
}
}
}
let mut violations = 0
let reports = sorted_keys(groups).map(key => {
let checks = rules.fields.map(field => {
let buckets : Map[String, Array[Int32]] = Map([])
let values : Map[String, Json] = Map([])
let mut missing = 0
for i in groups[key] {
let result = self.entries[i].result.unwrap()
let state = match result.get(field) {
Some(value) =>
Json::object({ "present": Json::boolean(true), "value": value })
None => {
missing += 1
Json::object({ "present": Json::boolean(false) })
}
}
let token = canonical_json(state)
values[token] = state
match buckets.get(token) {
Some(indices) => indices.push(i)
None => buckets[token] = [i]
}
}
let passed = buckets.length() == 1 &&
(!rules.require_present || missing == 0)
if !passed {
violations += 1
}
Json::object({
"field": Json::string(field),
"pass": Json::boolean(passed),
"missing_count": Json::number(missing.to_double()),
"states": Json::array(
sorted_keys(buckets).map(token => {
Json::object({
"state": values[token],
"occurrences": Json::array(
buckets[token].map(i => {
Json::object({
"query_index": Json::number(i.to_double()),
"data_path": Json::string(self.entries[i].path),
"trace": history_value(
self.entries[i].result.unwrap().trace(field),
),
})
}),
),
})
}),
),
})
})
Json::object({ "group": keys[key], "checks": Json::array(checks) })
})
let decision : ExpectationDecision = if self.entries.is_empty() ||
self.error_count() > 0 {
Indeterminate
} else if violations > 0 {
Violations
} else {
Pass
}
let name = match decision {
Pass => "pass"
Violations => "violations"
Indeterminate => "indeterminate"
}
{
decision,
violations,
value: Json::object({
"decision": Json::string(name),
"violation_count": Json::number(violations.to_double()),
"rules": rules.rules_value(),
"audit": self.report_value(),
"groups": Json::array(reports),
}),
}
}