use std::collections::BTreeMap;
use super::{grid_spatial, loc, str_attr, sub_dataset, Context};
use crate::annotations::encode::normalization_tolerance;
use crate::array::{DType, NdArray};
use crate::digest::{is_canonical_digest, parse_digest, DEFAULT_ALGO};
use crate::h5::attrs;
use crate::h5::data::{self, Kind};
use crate::h5::ops::{self, Node};
use crate::integrity::digest::{dataset_digest_inspected, STREAM_BYTES};
use crate::integrity::verify::{verify_root_reporting, ATTESTED_GROUPS};
use crate::integrity::{unbound, Binding};
use crate::json::{py_float, repr_int_tuple, repr_str};
use crate::validate::Diagnostic;
use crate::Result;
pub fn check_integrity(ctx: &mut Context) -> Result<Vec<Diagnostic>> {
let mut out = Vec::new();
let root = ctx.root.clone();
let mut algo_known = true;
if let Some(algo) = str_attr(&root, "digest_algo")? {
if parse_digest(&format!("{algo}:00")).is_err() {
algo_known = false;
out.push(ctx.err(
"E703",
"/",
format!("unsupported digest_algo {}; §2.1 permits sha256, sha512 and blake2b", repr_str(&algo)),
));
}
}
let mut probmaps = probmaps(ctx, &mut out)?;
let (result, unread) = verify_root_reporting(&root, ctx.attr_names.as_ref(), algo_known, &mut |path, block| {
if let Some(check) = probmaps.get_mut(path) {
check.feed(block);
}
Ok(())
})?;
for (path, e) in &unread {
out.push(match (path.is_empty(), e.code()) {
(true, None) => {
ctx.err("E001", "/", format!("the root's `content_id` could not be checked: {}", e.python_line()))
}
(true, Some(_)) => ctx.failed("/", e),
(false, _) => ctx.failed(format!("/{path}"), e),
});
}
let unreadable: Vec<&str> = unread.iter().map(|(path, _)| path.as_str()).collect();
for (path, check) in probmaps.iter_mut().filter(|(p, c)| !c.read && !unreadable.contains(&p.as_str())) {
let read = root.dataset(path).map_err(crate::Error::from).and_then(|ds| {
dataset_digest_inspected(
&ds,
path,
DEFAULT_ALGO,
STREAM_BYTES,
Some(&mut |block| {
check.feed(block);
Ok(())
}),
)
});
if let Err(e) = read {
out.push(ctx.failed(format!("/{path}"), &e));
}
}
for (path, check) in &probmaps {
out.extend(check.findings(ctx, path));
}
let digested = !(result.checked.is_empty() && result.malformed.is_empty() && result.unverifiable.is_empty());
let no_digests = !result.undigested.is_empty() && !digested && !unread.iter().any(|(path, _)| !path.is_empty());
if no_digests {
out.push(ctx.err("W901", "/", "no dataset carries a `digest` attribute"));
}
for path in &result.malformed {
out.push(ctx.err("E703", format!("/{path}"), "malformed digest string"));
}
for path in &result.mismatched {
out.push(ctx.err("E701", format!("/{path}"), "digest does not match the stored data"));
}
if result.content_id_ok() == Some(false) && ctx.projection {
out.push(ctx.err(
"W913",
"/",
format!(
"`content_id` does not recompute under the rules of MEDH5 {}: the root's integrity is unsupported in \
this projection, not verified (per-dataset digests were checked)",
crate::FORMAT_VERSION
),
));
} else if result.content_id_declared.as_deref().is_some_and(|d| !is_canonical_digest(d)) {
out.push(ctx.err(
"E703",
"/",
format!(
"malformed `content_id` {}: §13.1 spells a digest `<algo>:` and lowercase hex of the algorithm's \
length",
repr_str(result.content_id_declared.as_deref().unwrap_or_default())
),
));
} else if result.content_id_ok() == Some(false) {
out.push(ctx.err(
"E702",
"/",
format!(
"`content_id` {} does not match the computed {}",
result.content_id_declared.clone().unwrap_or_default(),
result.content_id_computed.clone().unwrap_or_default()
),
));
}
if result.content_id_declared.is_some() && !unreadable.contains(&"") {
for found in unbound(&root)? {
let at = format!("/{}", found.path);
match found.binding {
Binding::Undigested => {
let group = found.path.split('/').next().unwrap_or_default();
if !no_digests && ATTESTED_GROUPS.contains(&group) {
out.push(ctx.err(
"W915",
at,
"carries no `digest`, inside an object `content_id` covers: the root does not attest its \
bytes, so an edit to them changes no line (§13.1, §13.2)",
));
}
}
Binding::Unlisted => out.push(ctx.err(
"E702",
at,
"carries a digest no line of `content_id` covers: its object is reached first through `index/`, \
which the root excludes, or only through a soft link, so the root does not speak for its bytes",
)),
Binding::Elsewhere { first: Some(own) } => out.push(ctx.err(
"E704",
at,
format!(
"is not its object's own path, {}: `content_id` binds the object's bytes there, not here, so \
this name could lead to other bytes under an unchanged root",
repr_str(&format!("/{own}"))
),
)),
Binding::Elsewhere { first: None } => out.push(ctx.err(
"E704",
at,
"leads out of the sample --- a soft link the root's walk does not reach, or an external link --- \
so no line of `content_id` binds what is read here",
)),
}
}
}
for name in &result.stale_index {
out.push(ctx.err(
"W905",
format!("/index/{name}"),
"index `source_digest` does not match its annotation; readers must ignore this entry and rebuild it",
));
}
Ok(out)
}
struct ProbmapCheck {
class_ids: Vec<i64>,
spatial: Vec<usize>,
dtype: DType,
classes: usize,
summed: Option<usize>,
seen: usize,
sums: Vec<f64>,
bad: Option<(usize, usize, f64)>,
off: Option<(usize, f64)>,
unsummed: Option<String>,
read: bool,
}
fn probmaps(ctx: &Context, out: &mut Vec<Diagnostic>) -> Result<BTreeMap<String, ProbmapCheck>> {
let mut checks = BTreeMap::new();
let grids = ops::child_group(&ctx.root, "grids");
for (name, group) in ctx.children("annotations")? {
match probmap(&group, grids.as_ref()) {
Ok(Some(check)) => {
checks.insert(format!("annotations/{name}/data"), check);
}
Ok(None) => {}
Err(e) => out.push(ctx.failed(format!("/annotations/{name}"), &e)),
}
}
Ok(checks)
}
fn probmap(group: &Node, grids: Option<&hdf5::Group>) -> Result<Option<ProbmapCheck>> {
if str_attr(loc(group), "kind")?.as_deref() != Some("probmap") {
return Ok(None);
}
let Some(ds) = sub_dataset(group, "data") else { return Ok(None) };
let Kind::Numeric(dtype) = data::kind(&ds)? else { return Ok(None) };
let shape = ds.shape();
let Some((&classes, spatial)) = shape.split_first() else { return Ok(None) };
let class_ids = attrs::get_i64s(loc(group), "class_ids")?.unwrap_or_default();
let on_grid = match (str_attr(loc(group), "grid").ok().flatten(), grids) {
(Some(grid), Some(grids)) if ops::is_group(grids, &grid) => grid_spatial(grids, &grid)
.is_ok_and(|g| g.iter().map(|v| usize::try_from(*v).ok()).eq(spatial.iter().map(|v| Some(*v)))),
_ => false,
};
let sound = classes > 0 && classes == class_ids.len() && !spatial.is_empty() && on_grid;
let normalized = attrs::get_bool(loc(group), "normalized")?.unwrap_or(false);
Ok(Some(ProbmapCheck {
class_ids,
spatial: spatial.to_vec(),
dtype,
classes,
summed: if normalized && sound { spatial.iter().try_fold(1usize, |n, e| n.checked_mul(*e)) } else { None },
seen: 0,
sums: Vec::new(),
bad: None,
off: None,
unsummed: None,
read: false,
}))
}
impl ProbmapCheck {
fn feed(&mut self, block: &NdArray) {
self.read = true;
let plane = self.spatial.iter().try_fold(1usize, |n, e| n.checked_mul(*e)).unwrap_or(usize::MAX).max(1);
if self.seen == 0 && self.unsummed.is_none() {
if let Some(n) = self.summed {
if let Err(e) = self.sums.try_reserve_exact(n) {
self.unsummed = Some(format!("the sums of its {n} voxels do not fit in memory ({e})"));
self.summed = None;
} else {
self.sums.resize(n, 0.0);
}
}
}
let values = block.to_f64();
for (k, v) in values.iter().enumerate() {
let at = self.seen + k;
if self.bad.is_none() && !(0.0..=1.0).contains(v) {
self.bad = Some((at / plane, at % plane, *v));
}
if self.summed.is_some() {
self.sums[at % plane] += *v;
}
}
self.seen += values.len();
if self.seen / plane >= self.classes && self.summed.is_some() {
let (dtype, classes) = (self.dtype, self.classes);
self.off = self
.sums
.iter()
.position(|s| (*s - 1.0).abs() > normalization_tolerance(dtype, classes, *s))
.map(|voxel| (voxel, self.sums[voxel]));
self.sums = Vec::new();
}
}
fn voxel(&self, flat: usize) -> String {
let mut index = vec![0; self.spatial.len()];
let mut rest = flat;
for (axis, extent) in self.spatial.iter().enumerate().rev() {
let extent = (*extent).max(1);
index[axis] = rest % extent;
rest /= extent;
}
repr_int_tuple(&index)
}
fn findings(&self, ctx: &Context, path: &str) -> Vec<Diagnostic> {
let mut out = Vec::new();
if let Some((row, voxel, value)) = self.bad {
let class = self.class_ids.get(row).map_or_else(|| format!("row {row}"), |c| format!("class {c}"));
out.push(ctx.err(
"E411",
format!("/{path}"),
format!(
"a stored probability is {} ({class}, voxel {}); §7.5 stores values in [0, 1]",
py_float(value),
self.voxel(voxel)
),
));
} else if let Some((voxel, sum)) = self.off {
out.push(ctx.err(
"E404",
format!("/{path}"),
format!(
"`normalized` is true, but the classes sum to {} at voxel {}; §7.5 has them sum to 1 at every \
voxel",
py_float(sum),
self.voxel(voxel)
),
));
}
if let Some(why) = &self.unsummed {
out.push(ctx.err("E001", format!("/{path}"), format!("`normalized` could not be checked: {why}")));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unread(classes: usize, spatial: &[usize]) -> ProbmapCheck {
ProbmapCheck {
class_ids: (1..=classes as i64).collect(),
spatial: spatial.to_vec(),
dtype: DType::F32,
classes,
summed: Some(spatial.iter().product()),
seen: 0,
sums: Vec::new(),
bad: None,
off: None,
unsummed: None,
read: false,
}
}
#[test]
fn s7_5_a_probability_map_is_judged_from_blocks_of_any_size() {
let first = [0.25f32, 1.5, 0.5, 0.5, 0.5, 0.5];
let second = [0.75f32, -0.5, 0.5, 0.5, 0.5, 0.25];
let flat: Vec<f32> = first.iter().chain(&second).copied().collect();
for size in 1..=flat.len() {
let mut check = unread(2, &[2, 3]);
for block in flat.chunks(size) {
check.feed(&NdArray::from_vec(&[block.len()], block.to_vec()).unwrap());
}
assert_eq!(check.bad, Some((0, 1, 1.5)), "blocks of {size}");
assert_eq!(check.off, Some((5, 0.75)), "blocks of {size}");
assert_eq!(check.voxel(5), "(1, 2)");
assert!(check.sums.is_empty(), "freed once judged");
}
}
}