1use std::collections::{BTreeMap, BTreeSet};
8use std::fmt::Write as _;
9
10use glam::Mat4;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use crate::diff::MetricDelta;
15use crate::evaluation::{
16 Applicability, CheckEvaluation, ConfigurationState, EvaluationState, SelectionState,
17};
18use crate::measure::{
19 Aabb, AssetMeasurements, ClipMeasurements, ImageMeasurements, LinearTransformClassification,
20 LinearTransformMeasurements, MaterialDefinitionMeasurements, SkeletonNodeLocalRestMeasurements,
21 SkeletonRestWorldMatrixUnavailableReason, SkinDerivedMatrixMeasurements,
22 SkinDerivedMatrixUnavailableReason, TextureMeasurements, assess_inverse_bind,
23 measure_linear_transform, summarize_skin_bind_linear,
24};
25use crate::model::{
26 DecodedImageColorType, MaterialResourceCoverage, SourceInverseBindAccessorStatus,
27 SourceSkeletonCoverage,
28};
29use crate::profile::ResolvedRoles;
30use crate::{Document, Severity};
31
32pub const OUTPUT_SCHEMA_VERSION: u32 = 7;
34pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:7";
36pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 13;
38pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:13";
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct ToolSource {
44 revision: Option<String>,
45 dirty: Option<bool>,
46}
47
48impl ToolSource {
49 pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
56 let revision = revision.filter(|revision| {
57 revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
58 });
59 Self { revision, dirty }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct ToolInfo {
66 name: &'static str,
67 version: &'static str,
68 source: ToolSource,
69}
70
71impl ToolInfo {
72 pub fn animsmith(source: ToolSource) -> Self {
75 Self {
76 name: "animsmith",
77 version: env!("CARGO_PKG_VERSION"),
78 source,
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
88pub struct InputIdentity {
89 sha256: String,
90 bytes: u64,
91}
92
93#[must_use]
99pub fn sha256_hex(bytes: &[u8]) -> String {
100 let mut hex = String::with_capacity(64);
101 for byte in Sha256::digest(bytes) {
102 let _ = write!(hex, "{byte:02x}");
103 }
104 hex
105}
106
107impl InputIdentity {
108 pub fn from_bytes(bytes: &[u8]) -> Self {
110 Self {
111 sha256: sha256_hex(bytes),
112 bytes: bytes.len() as u64,
113 }
114 }
115
116 pub fn sha256(&self) -> &str {
118 &self.sha256
119 }
120
121 pub fn bytes(&self) -> u64 {
123 self.bytes
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129pub struct RigInfo {
130 profile: String,
131 resolved_roles: BTreeMap<&'static str, String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
136#[non_exhaustive]
137pub enum RigInfoError {
138 #[error(
140 "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
141 )]
142 InvalidBoneId {
143 role: &'static str,
145 bone: usize,
147 bone_count: usize,
149 },
150 #[error(
152 "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
153 )]
154 BoneNameMismatch {
155 role: &'static str,
157 bone: usize,
159 expected: String,
161 found: String,
163 },
164}
165
166impl RigInfo {
167 pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
176 let resolved_roles = roles
177 .iter_with_names()
178 .map(|(role, bone, expected_name)| {
179 let name = doc
180 .skeleton
181 .bones
182 .get(bone)
183 .ok_or(RigInfoError::InvalidBoneId {
184 role: role.as_str(),
185 bone,
186 bone_count: doc.skeleton.bones.len(),
187 })?;
188 if name.name != expected_name {
189 return Err(RigInfoError::BoneNameMismatch {
190 role: role.as_str(),
191 bone,
192 expected: expected_name.to_owned(),
193 found: name.name.clone(),
194 });
195 }
196 Ok((role.as_str(), name.name.clone()))
197 })
198 .collect::<Result<_, _>>()?;
199 Ok(Self {
200 profile: roles.profile.clone(),
201 resolved_roles,
202 })
203 }
204}
205
206#[derive(Debug, Clone, Serialize)]
209pub struct MeasurementContract {
210 schema_version: u32,
211 schema: &'static str,
212 clips: BTreeMap<String, ClipMeasurements>,
213 #[serde(flatten)]
214 assets: AssetMeasurements,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
219#[non_exhaustive]
220pub enum MeasurementContractError {
221 #[error("measurement value {path} must be finite")]
223 NonFiniteValue {
224 path: String,
226 },
227 #[error("measurement structure {path} is invalid: {reason}")]
229 InvalidStructure {
230 path: String,
232 reason: String,
234 },
235}
236
237impl MeasurementContract {
238 pub fn new(
245 clips: BTreeMap<String, ClipMeasurements>,
246 assets: AssetMeasurements,
247 ) -> Result<Self, MeasurementContractError> {
248 validate_measurements(&clips, &assets)?;
249 Ok(Self {
250 schema_version: MEASUREMENTS_SCHEMA_VERSION,
251 schema: MEASUREMENTS_SCHEMA_ID,
252 clips,
253 assets,
254 })
255 }
256
257 pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
259 &self.clips
260 }
261
262 pub fn assets(&self) -> &AssetMeasurements {
264 &self.assets
265 }
266
267 pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
269 (self.clips, self.assets)
270 }
271}
272
273fn validate_measurements(
274 clips: &BTreeMap<String, ClipMeasurements>,
275 assets: &AssetMeasurements,
276) -> Result<(), MeasurementContractError> {
277 let finite = |value: f64, path: String| {
278 value
279 .is_finite()
280 .then_some(())
281 .ok_or(MeasurementContractError::NonFiniteValue { path })
282 };
283 for (clip_name, clip) in clips {
284 finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
285 for (bone, value) in &clip.bone_rotation_range_deg {
286 finite(
287 *value,
288 format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
289 )?;
290 }
291 if let Some(loop_continuity) = &clip.loop_continuity {
292 if loop_continuity.bones.is_empty() {
293 return Err(MeasurementContractError::InvalidStructure {
294 path: format!("clips[{clip_name:?}].loop_continuity.bones"),
295 reason: "present loop-continuity evidence must contain at least one bone"
296 .into(),
297 });
298 }
299 for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
300 let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
301 if usize::try_from(bone.bone_index) != Ok(expected_index) {
302 return Err(MeasurementContractError::InvalidStructure {
303 path: format!("{path}.bone_index"),
304 reason: format!(
305 "expected skeleton-order index {expected_index}, found {}",
306 bone.bone_index
307 ),
308 });
309 }
310 for (field, value) in [
311 ("position_delta_m", bone.position_delta_m),
312 ("rotation_delta_deg", bone.rotation_delta_deg),
313 ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
314 (
315 "seam_angular_velocity_delta_degps",
316 bone.seam_angular_velocity_delta_degps,
317 ),
318 ] {
319 finite(value, format!("{path}.{field}"))?;
320 if value < 0.0 {
321 return Err(MeasurementContractError::InvalidStructure {
322 path: format!("{path}.{field}"),
323 reason: "loop-continuity deltas must be non-negative".into(),
324 });
325 }
326 }
327 }
328 }
329 if let Some(frame_grid) = clip.frame_grid {
330 let path = format!("clips[{clip_name:?}].frame_grid");
331 finite(frame_grid.fps, format!("{path}.fps"))?;
332 if frame_grid.fps <= 0.0 {
333 return Err(MeasurementContractError::InvalidStructure {
334 path: format!("{path}.fps"),
335 reason: "declared frame-grid FPS must be positive".into(),
336 });
337 }
338 if frame_grid.frame_intervals == 0 {
339 return Err(MeasurementContractError::InvalidStructure {
340 path: format!("{path}.frame_intervals"),
341 reason: "declared frame-grid evidence must contain at least one interval"
342 .into(),
343 });
344 }
345 }
346 if let Some(value) = clip.loop_seam_ratio {
347 finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
348 }
349 if let Some(gait) = &clip.gait {
350 if let Some(value) = gait.phase {
351 finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
352 }
353 finite(
354 gait.lr_amplitude_m,
355 format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
356 )?;
357 }
358 if let Some(value) = clip.speed_mps {
359 finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
360 }
361 }
362 let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
363 path,
364 reason: reason.to_owned(),
365 };
366 let finite_aabb = |aabb: &Aabb, path: &str| {
367 for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
368 for (axis, value) in values.into_iter().enumerate() {
369 finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
370 }
371 }
372 for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
373 if min > max {
374 return Err(invalid(
375 format!("{path}.min[{axis}]"),
376 "AABB minimum cannot exceed maximum",
377 ));
378 }
379 }
380 Ok(())
381 };
382
383 let mut mesh_indices = BTreeSet::new();
384 for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
385 if !mesh_indices.insert(mesh.mesh_index) {
386 return Err(invalid(
387 format!("mesh_definitions[{index}].mesh_index"),
388 "mesh_index must be unique",
389 ));
390 }
391 if let Some(aabb) = &mesh.geometry_aabb {
392 finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
393 }
394 if let Some(centroid) = mesh.geometry_centroid {
395 for (axis, value) in centroid.into_iter().enumerate() {
396 finite(
397 f64::from(value),
398 format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
399 )?;
400 }
401 }
402 if let Some(value) = mesh.weight_sum_min {
403 finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
404 }
405 if let Some(value) = mesh.weight_sum_max {
406 finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
407 }
408 let mut previous_set_index = None;
409 for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
410 let path = format!(
411 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
412 );
413 if set.set_index == 0 {
414 return Err(invalid(path, "set_index must be at least 1"));
415 }
416 if !set.joints_present && !set.weights_present {
417 return Err(invalid(
418 format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
419 "an additional influence set must declare joints, weights, or both",
420 ));
421 }
422 if set.joints_without_weights_present && !set.joints_present {
423 return Err(invalid(
424 format!(
425 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
426 ),
427 "joints_without_weights_present requires joints_present",
428 ));
429 }
430 if set.weights_without_joints_present && !set.weights_present {
431 return Err(invalid(
432 format!(
433 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
434 ),
435 "weights_without_joints_present requires weights_present",
436 ));
437 }
438 if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
439 return Err(invalid(
440 format!(
441 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
442 ),
443 "joints_without_weights_present is required when weights_present is false",
444 ));
445 }
446 if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
447 return Err(invalid(
448 format!(
449 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
450 ),
451 "weights_without_joints_present is required when joints_present is false",
452 ));
453 }
454 if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
455 return Err(invalid(
456 path,
457 "set_index values must be strictly increasing and unique",
458 ));
459 }
460 previous_set_index = Some(set.set_index);
461 }
462 }
463
464 let mut node_indices = BTreeSet::new();
465 for (index, instance) in assets.node_instances.iter().enumerate() {
466 if !node_indices.insert(instance.node_index) {
467 return Err(invalid(
468 format!("node_instances[{index}].node_index"),
469 "node_index must be unique",
470 ));
471 }
472 if !mesh_indices.contains(&instance.mesh_index) {
473 return Err(invalid(
474 format!("node_instances[{index}].mesh_index"),
475 "mesh_index must reference a mesh definition",
476 ));
477 }
478 match (
479 instance.static_node_world_aabb.as_ref(),
480 instance.static_node_world_aabb_unavailable_reason,
481 ) {
482 (Some(aabb), None) => finite_aabb(
483 aabb,
484 &format!("node_instances[{index}].static_node_world_aabb"),
485 )?,
486 (None, Some(_)) => {}
487 (Some(_), Some(_)) => {
488 return Err(invalid(
489 format!("node_instances[{index}]"),
490 "an available static node AABB cannot have an unavailable reason",
491 ));
492 }
493 (None, None) => {
494 return Err(invalid(
495 format!("node_instances[{index}]"),
496 "a missing static node AABB requires an unavailable reason",
497 ));
498 }
499 }
500 }
501
502 let mut scene_indices = BTreeSet::new();
503 for (index, scene) in assets.scenes.iter().enumerate() {
504 if !scene_indices.insert(scene.scene_index) {
505 return Err(invalid(
506 format!("scenes[{index}].scene_index"),
507 "scene_index must be unique",
508 ));
509 }
510 if scene.excluded_instance_count > scene.instance_count {
511 return Err(invalid(
512 format!("scenes[{index}].excluded_instance_count"),
513 "excluded_instance_count cannot exceed instance_count",
514 ));
515 }
516 let available = scene.instance_count - scene.excluded_instance_count;
517 match (&scene.static_scene_world_aabb, available) {
518 (Some(aabb), 1..) => {
519 finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
520 }
521 (None, 0) => {}
522 (Some(_), 0) => {
523 return Err(invalid(
524 format!("scenes[{index}].static_scene_world_aabb"),
525 "a scene with no available instances cannot have an AABB",
526 ));
527 }
528 (None, _) => {
529 return Err(invalid(
530 format!("scenes[{index}].static_scene_world_aabb"),
531 "a scene with available instances requires an AABB",
532 ));
533 }
534 }
535 }
536 if let Some(default_scene_index) = assets.default_scene_index
537 && !scene_indices.contains(&default_scene_index)
538 {
539 return Err(invalid(
540 "default_scene_index".into(),
541 "default_scene_index must reference a declared scene",
542 ));
543 }
544 validate_skeleton_measurements(assets, &invalid)?;
545 validate_material_resources(assets, &invalid)?;
546 Ok(())
547}
548
549fn validate_linear_transform_fields(
550 linear: &LinearTransformMeasurements,
551 path: &str,
552 invalid: &impl Fn(String, &str) -> MeasurementContractError,
553) -> Result<(), MeasurementContractError> {
554 let numeric_fields_present = linear.axis_lengths.is_some()
555 && linear.determinant.is_some()
556 && linear.orientation.is_some();
557 if linear.classification == LinearTransformClassification::NonFinite {
558 if linear.axis_lengths.is_some()
559 || linear.determinant.is_some()
560 || linear.orientation.is_some()
561 || linear.uniform_scale.is_some()
562 {
563 return Err(invalid(
564 path.into(),
565 "a non_finite classification cannot carry numeric linear-transform facts",
566 ));
567 }
568 return Ok(());
569 }
570 if !numeric_fields_present {
571 return Err(invalid(
572 path.into(),
573 "a finite classification requires axis_lengths, determinant, and orientation",
574 ));
575 }
576 for (axis, value) in linear
577 .axis_lengths
578 .expect("presence checked")
579 .into_iter()
580 .enumerate()
581 {
582 if !value.is_finite() {
583 return Err(MeasurementContractError::NonFiniteValue {
584 path: format!("{path}.axis_lengths[{axis}]"),
585 });
586 }
587 if value < 0.0 {
588 return Err(invalid(
589 format!("{path}.axis_lengths[{axis}]"),
590 "axis lengths must be non-negative",
591 ));
592 }
593 }
594 if !linear.determinant.expect("presence checked").is_finite() {
595 return Err(MeasurementContractError::NonFiniteValue {
596 path: format!("{path}.determinant"),
597 });
598 }
599 if let Some(scale) = linear.uniform_scale {
600 if !scale.is_finite() {
601 return Err(MeasurementContractError::NonFiniteValue {
602 path: format!("{path}.uniform_scale"),
603 });
604 }
605 if scale < 0.0 {
606 return Err(invalid(
607 format!("{path}.uniform_scale"),
608 "uniform scale must be non-negative",
609 ));
610 }
611 }
612 Ok(())
613}
614
615fn validate_skeleton_measurements(
616 assets: &AssetMeasurements,
617 invalid: &impl Fn(String, &str) -> MeasurementContractError,
618) -> Result<(), MeasurementContractError> {
619 if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
620 if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
621 return Err(invalid(
622 "skeleton_source_coverage".into(),
623 "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
624 ));
625 }
626 return Ok(());
627 }
628
629 let finite_matrix = |matrix: &[f32; 16], path: &str| {
630 for (component, value) in matrix.iter().enumerate() {
631 if !value.is_finite() {
632 return Err(MeasurementContractError::NonFiniteValue {
633 path: format!("{path}[{component}]"),
634 });
635 }
636 }
637 Ok(())
638 };
639 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
640 if node.node_index != offset {
641 return Err(invalid(
642 format!("skeleton_nodes[{offset}].node_index"),
643 "node_index must be contiguous and match source order",
644 ));
645 }
646 match &node.local_rest {
647 SkeletonNodeLocalRestMeasurements::Trs {
648 translation_parent_space_m,
649 rotation_xyzw,
650 scale,
651 } => {
652 for (field, values) in [
653 (
654 "translation_parent_space_m",
655 translation_parent_space_m.as_slice(),
656 ),
657 ("rotation_xyzw", rotation_xyzw.as_slice()),
658 ("scale", scale.as_slice()),
659 ] {
660 for (component, value) in values.iter().enumerate() {
661 if !value.is_finite() {
662 return Err(MeasurementContractError::NonFiniteValue {
663 path: format!(
664 "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
665 ),
666 });
667 }
668 }
669 }
670 }
671 SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
672 matrix,
673 &format!("skeleton_nodes[{offset}].local_rest.matrix"),
674 )?,
675 SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
676 }
677 let node_path = format!("skeleton_nodes[{offset}]");
678 validate_linear_transform_fields(
679 &node.rest_world_linear,
680 &format!("{node_path}.rest_world_linear"),
681 invalid,
682 )?;
683 match (
684 node.rest_world_matrix.as_ref(),
685 node.rest_world_translation_m.as_ref(),
686 node.rest_world_matrix_unavailable_reason,
687 ) {
688 (Some(matrix), Some(translation), None) => {
689 finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
690 for (component, value) in translation.iter().enumerate() {
691 if !value.is_finite() {
692 return Err(MeasurementContractError::NonFiniteValue {
693 path: format!("{node_path}.rest_world_translation_m[{component}]"),
694 });
695 }
696 }
697 let expected_translation = [matrix[12], matrix[13], matrix[14]];
698 if *translation != expected_translation {
699 return Err(invalid(
700 format!("{node_path}.rest_world_translation_m"),
701 "rest_world_translation_m must equal the rest-world matrix translation column",
702 ));
703 }
704 let expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
705 if node.rest_world_linear != expected_linear {
706 return Err(invalid(
707 format!("{node_path}.rest_world_linear"),
708 "rest_world_linear must be derived from rest_world_matrix",
709 ));
710 }
711 }
712 (None, None, Some(_)) => {
713 if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
714 {
715 return Err(invalid(
716 format!("{node_path}.rest_world_linear"),
717 "an unavailable rest-world matrix requires a non_finite linear classification",
718 ));
719 }
720 }
721 (Some(_), Some(_), Some(_)) => {
722 return Err(invalid(
723 node_path,
724 "an available rest_world_matrix cannot have an unavailable reason",
725 ));
726 }
727 _ => {
728 return Err(invalid(
729 node_path,
730 "rest-world matrix, translation, and unavailable reason fields are inconsistent",
731 ));
732 }
733 }
734 }
735 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
736 if let Some(parent) = node.parent_node_index
737 && parent >= assets.skeleton_nodes.len()
738 {
739 return Err(invalid(
740 format!("skeleton_nodes[{offset}].parent_node_index"),
741 "parent_node_index must reference a skeleton node",
742 ));
743 }
744 let mut previous_scene = None;
745 for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
746 if !assets
747 .scenes
748 .iter()
749 .any(|scene| scene.scene_index == *scene_index)
750 {
751 return Err(invalid(
752 format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
753 "scene_root_indices values must reference declared scenes",
754 ));
755 }
756 if previous_scene.is_some_and(|previous| previous >= *scene_index) {
757 return Err(invalid(
758 format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
759 "scene_root_indices values must be strictly increasing and unique",
760 ));
761 }
762 previous_scene = Some(*scene_index);
763 }
764 }
765 let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
766 for start in 0..assets.skeleton_nodes.len() {
767 if visits.get(start) != Some(&ParentVisit::Unvisited) {
768 continue;
769 }
770 let mut path = Vec::new();
771 let mut current = start;
772 loop {
773 match visits.get(current).copied().ok_or_else(|| {
774 invalid(
775 format!("skeleton_nodes[{current}].parent_node_index"),
776 "parent_node_index must reference a skeleton node",
777 )
778 })? {
779 ParentVisit::Done => break,
780 ParentVisit::Visiting => {
781 return Err(invalid(
782 format!("skeleton_nodes[{current}].parent_node_index"),
783 "source node parent graph must be acyclic",
784 ));
785 }
786 ParentVisit::Unvisited => {
787 *visits.get_mut(current).ok_or_else(|| {
788 invalid(
789 format!("skeleton_nodes[{current}].parent_node_index"),
790 "parent_node_index must reference a skeleton node",
791 )
792 })? = ParentVisit::Visiting;
793 path.push(current);
794 match assets
795 .skeleton_nodes
796 .get(current)
797 .ok_or_else(|| {
798 invalid(
799 format!("skeleton_nodes[{current}].parent_node_index"),
800 "parent_node_index must reference a skeleton node",
801 )
802 })?
803 .parent_node_index
804 {
805 Some(parent) => current = parent,
806 None => break,
807 }
808 }
809 }
810 }
811 for node_index in path {
812 *visits.get_mut(node_index).ok_or_else(|| {
813 invalid(
814 format!("skeleton_nodes[{node_index}].parent_node_index"),
815 "parent_node_index must reference a skeleton node",
816 )
817 })? = ParentVisit::Done;
818 }
819 }
820
821 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
822 let local_rest_available = !matches!(
823 node.local_rest,
824 SkeletonNodeLocalRestMeasurements::Unavailable { .. }
825 );
826 let path = format!("skeleton_nodes[{offset}]");
827 if !local_rest_available {
828 if node.rest_world_matrix.is_some()
829 || node.rest_world_matrix_unavailable_reason
830 != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
831 {
832 return Err(invalid(
833 path,
834 "an unavailable local_rest requires a non_finite_local_rest rest-world result",
835 ));
836 }
837 continue;
838 }
839
840 let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
841 let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
842 invalid(
843 format!("skeleton_nodes[{offset}].parent_node_index"),
844 "parent_node_index must reference a skeleton node",
845 )
846 })?;
847 if parent.rest_world_matrix.is_none() {
848 Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
849 } else {
850 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
851 }
852 } else {
853 None
854 };
855 match (
856 node.rest_world_matrix.is_some(),
857 expected_unavailable_reason,
858 ) {
859 (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
860 }
861 (false, Some(expected))
862 if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
863 _ => {
864 return Err(invalid(
865 path,
866 "rest-world availability must agree with local rest and parent rest-world evidence",
867 ));
868 }
869 }
870 }
871
872 for (offset, skin) in assets.skins.iter().enumerate() {
873 if skin.skin_index != offset {
874 return Err(invalid(
875 format!("skins[{offset}].skin_index"),
876 "skin_index must be contiguous and match source order",
877 ));
878 }
879 if let Some(root) = skin.skeleton_root_node_index
880 && root >= assets.skeleton_nodes.len()
881 {
882 return Err(invalid(
883 format!("skins[{offset}].skeleton_root_node_index"),
884 "skeleton_root_node_index must reference a skeleton node",
885 ));
886 }
887 for (joint_offset, joint) in skin.joints.iter().enumerate() {
888 if joint.joint_index != joint_offset {
889 return Err(invalid(
890 format!("skins[{offset}].joints[{joint_offset}].joint_index"),
891 "joint_index must be contiguous and match declared skin order",
892 ));
893 }
894 if joint.node_index >= assets.skeleton_nodes.len() {
895 return Err(invalid(
896 format!("skins[{offset}].joints[{joint_offset}].node_index"),
897 "joint node_index must reference a skeleton node",
898 ));
899 }
900 }
901 match skin.inverse_bind_accessor.status {
902 SourceInverseBindAccessorStatus::Absent => {
903 if skin.inverse_bind_accessor.declared_count.is_some()
904 || !skin.inverse_bind_accessor.matrices.is_empty()
905 {
906 return Err(invalid(
907 format!("skins[{offset}].inverse_bind_accessor"),
908 "an absent inverse-bind declaration has no declared count or matrices",
909 ));
910 }
911 }
912 SourceInverseBindAccessorStatus::EmptyAccessor => {
913 if skin.inverse_bind_accessor.declared_count != Some(0)
914 || !skin.inverse_bind_accessor.matrices.is_empty()
915 {
916 return Err(invalid(
917 format!("skins[{offset}].inverse_bind_accessor"),
918 "an empty inverse-bind declaration has declared_count 0 and no matrices",
919 ));
920 }
921 }
922 SourceInverseBindAccessorStatus::Available => {
923 if skin.inverse_bind_accessor.declared_count
924 != Some(skin.inverse_bind_accessor.matrices.len())
925 || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
926 {
927 return Err(invalid(
928 format!("skins[{offset}].inverse_bind_accessor"),
929 "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
930 ));
931 }
932 }
933 SourceInverseBindAccessorStatus::CountMismatch => {
934 if skin.inverse_bind_accessor.declared_count
935 != Some(skin.inverse_bind_accessor.matrices.len())
936 || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
937 {
938 return Err(invalid(
939 format!("skins[{offset}].inverse_bind_accessor"),
940 "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
941 ));
942 }
943 }
944 SourceInverseBindAccessorStatus::Unreadable => {
945 if skin.inverse_bind_accessor.declared_count.is_none()
946 || !skin.inverse_bind_accessor.matrices.is_empty()
947 {
948 return Err(invalid(
949 format!("skins[{offset}].inverse_bind_accessor"),
950 "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
951 ));
952 }
953 }
954 }
955 for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
956 finite_matrix(
957 matrix,
958 &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
959 )?;
960 }
961 for (joint_offset, joint) in skin.joints.iter().enumerate() {
962 let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
963 let joint_bind_path =
964 format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
965 validate_derived_matrix(
966 &joint.joint_bind_to_mesh,
967 &joint_bind_path,
968 &finite_matrix,
969 invalid,
970 )?;
971 validate_derived_reason_compatibility(
972 &joint.joint_bind_to_mesh,
973 skin.inverse_bind_accessor.status,
974 skin.inverse_bind_accessor.matrices.len(),
975 joint_offset,
976 &joint_bind_path,
977 DerivedMatrixDomain::JointBindToMesh,
978 invalid,
979 )?;
980 validate_derived_source(
981 &joint.joint_bind_to_mesh,
982 expected_source,
983 None,
984 &joint_bind_path,
985 DerivedMatrixDomain::JointBindToMesh,
986 invalid,
987 )?;
988
989 let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
990 validate_derived_matrix(
991 &joint.mesh_bind_world,
992 &mesh_bind_path,
993 &finite_matrix,
994 invalid,
995 )?;
996 validate_derived_reason_compatibility(
997 &joint.mesh_bind_world,
998 skin.inverse_bind_accessor.status,
999 skin.inverse_bind_accessor.matrices.len(),
1000 joint_offset,
1001 &mesh_bind_path,
1002 DerivedMatrixDomain::MeshBindWorld,
1003 invalid,
1004 )?;
1005 let joint_rest_world_available = assets
1006 .skeleton_nodes
1007 .get(joint.node_index)
1008 .ok_or_else(|| {
1009 invalid(
1010 format!("skins[{offset}].joints[{joint_offset}].node_index"),
1011 "joint node_index must reference a skeleton node",
1012 )
1013 })?
1014 .rest_world_matrix
1015 .is_some();
1016 let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1017 .rest_world_matrix
1018 .as_ref();
1019 validate_mesh_bind_world_reason_compatibility(
1020 &joint.mesh_bind_world,
1021 joint_rest_world_available,
1022 &mesh_bind_path,
1023 invalid,
1024 )?;
1025 validate_derived_source(
1026 &joint.mesh_bind_world,
1027 expected_source,
1028 joint_rest_world,
1029 &mesh_bind_path,
1030 DerivedMatrixDomain::MeshBindWorld,
1031 invalid,
1032 )?;
1033 }
1034 if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1035 && !scale.is_finite()
1036 {
1037 return Err(MeasurementContractError::NonFiniteValue {
1038 path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1039 });
1040 }
1041 let expected_summary = summarize_skin_bind_linear(&skin.joints);
1042 if skin.joint_bind_linear_summary != expected_summary {
1043 return Err(invalid(
1044 format!("skins[{offset}].joint_bind_linear_summary"),
1045 "joint-bind linear summary must match the skin joint observations",
1046 ));
1047 }
1048 let mut previous_attachment_node = None;
1049 for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1050 if attachment.node_index >= assets.skeleton_nodes.len() {
1051 return Err(invalid(
1052 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1053 "attachment node_index must reference a skeleton node",
1054 ));
1055 }
1056 if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1057 return Err(invalid(
1058 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1059 "attachment node_index values must be strictly increasing and unique",
1060 ));
1061 }
1062 previous_attachment_node = Some(attachment.node_index);
1063 }
1064 }
1065 Ok(())
1066}
1067
1068fn validate_derived_reason_compatibility(
1069 matrix: &SkinDerivedMatrixMeasurements,
1070 status: SourceInverseBindAccessorStatus,
1071 readable_matrix_count: usize,
1072 joint_index: usize,
1073 path: &str,
1074 domain: DerivedMatrixDomain,
1075 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1076) -> Result<(), MeasurementContractError> {
1077 let requires_accessor_reason = match status {
1078 SourceInverseBindAccessorStatus::Absent => {
1079 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1080 }
1081 SourceInverseBindAccessorStatus::EmptyAccessor => {
1082 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1083 }
1084 SourceInverseBindAccessorStatus::Unreadable => {
1085 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1086 }
1087 SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1088 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1089 }
1090 SourceInverseBindAccessorStatus::Available
1091 | SourceInverseBindAccessorStatus::CountMismatch => None,
1092 };
1093 if let Some(expected) = requires_accessor_reason {
1094 if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1095 return Err(invalid(
1096 path.into(),
1097 "derived matrices without a usable inverse bind must carry the matching accessor reason",
1098 ));
1099 }
1100 } else {
1101 match (domain, matrix.unavailable_reason) {
1102 (
1103 _,
1104 Some(
1105 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1106 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1107 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1108 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1109 ),
1110 ) => {
1111 return Err(invalid(
1112 format!("{path}.unavailable_reason"),
1113 "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1114 ));
1115 }
1116 (
1117 DerivedMatrixDomain::JointBindToMesh,
1118 Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1119 ) => {
1120 return Err(invalid(
1121 format!("{path}.unavailable_reason"),
1122 "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1123 ));
1124 }
1125 (
1126 DerivedMatrixDomain::MeshBindWorld,
1127 Some(
1128 SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1129 | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1130 | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1131 ),
1132 ) => {
1133 return Err(invalid(
1134 format!("{path}.unavailable_reason"),
1135 "mesh_bind_world does not require an invertible inverse-bind matrix",
1136 ));
1137 }
1138 _ => {}
1139 }
1140 }
1141 Ok(())
1142}
1143
1144fn validate_mesh_bind_world_reason_compatibility(
1145 matrix: &SkinDerivedMatrixMeasurements,
1146 joint_rest_world_available: bool,
1147 path: &str,
1148 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1149) -> Result<(), MeasurementContractError> {
1150 match matrix.unavailable_reason {
1151 Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1152 if joint_rest_world_available =>
1153 {
1154 Err(invalid(
1155 format!("{path}.unavailable_reason"),
1156 "an available joint rest-world matrix cannot be reported as unavailable",
1157 ))
1158 }
1159 Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1160 if !joint_rest_world_available =>
1161 {
1162 Err(invalid(
1163 format!("{path}.unavailable_reason"),
1164 "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1165 ))
1166 }
1167 _ => Ok(()),
1168 }
1169}
1170
1171#[derive(Clone, Copy, PartialEq, Eq)]
1172enum ParentVisit {
1173 Unvisited,
1174 Visiting,
1175 Done,
1176}
1177
1178#[derive(Clone, Copy)]
1179enum DerivedMatrixDomain {
1180 JointBindToMesh,
1181 MeshBindWorld,
1182}
1183
1184fn validate_derived_source(
1185 measurements: &SkinDerivedMatrixMeasurements,
1186 expected_source: Option<&[f32; 16]>,
1187 joint_rest_world: Option<&[f32; 16]>,
1188 path: &str,
1189 domain: DerivedMatrixDomain,
1190 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1191) -> Result<(), MeasurementContractError> {
1192 if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1193 return Err(invalid(
1194 format!("{path}.source_inverse_bind_matrix"),
1195 "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1196 ));
1197 }
1198 let Some(source) = expected_source else {
1199 if measurements.inversion_quality.is_some() {
1200 return Err(invalid(
1201 format!("{path}.inversion_quality"),
1202 "inversion quality requires a readable source inverse-bind matrix",
1203 ));
1204 }
1205 return Ok(());
1206 };
1207 let raw = Mat4::from_cols_array(source);
1208 match domain {
1209 DerivedMatrixDomain::JointBindToMesh => {
1210 let assessment = assess_inverse_bind(raw);
1211 if measurements.inversion_quality != assessment.quality {
1212 return Err(invalid(
1213 format!("{path}.inversion_quality"),
1214 "inversion quality must be derived from the source linear 3x3",
1215 ));
1216 }
1217 match assessment.inverse {
1218 Ok(inverse) => {
1219 if measurements.matrix != Some(inverse.to_cols_array())
1220 || measurements.unavailable_reason.is_some()
1221 {
1222 return Err(invalid(
1223 path.into(),
1224 "a trustworthy source inverse-bind matrix requires its exact inverse",
1225 ));
1226 }
1227 }
1228 Err(reason) => {
1229 if measurements.matrix.is_some()
1230 || measurements.unavailable_reason != Some(reason)
1231 {
1232 return Err(invalid(
1233 path.into(),
1234 "an untrustworthy source inverse-bind matrix requires its derived reason",
1235 ));
1236 }
1237 }
1238 }
1239 }
1240 DerivedMatrixDomain::MeshBindWorld => {
1241 if measurements.inversion_quality.is_some() {
1242 return Err(invalid(
1243 format!("{path}.inversion_quality"),
1244 "mesh_bind_world does not invert its source matrix",
1245 ));
1246 }
1247 if let Some(world) = joint_rest_world {
1248 let expected = Mat4::from_cols_array(world) * raw;
1249 if expected.to_cols_array().into_iter().all(f32::is_finite) {
1250 if measurements.matrix != Some(expected.to_cols_array())
1251 || measurements.unavailable_reason.is_some()
1252 {
1253 return Err(invalid(
1254 path.into(),
1255 "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1256 ));
1257 }
1258 } else if measurements.unavailable_reason
1259 != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1260 {
1261 return Err(invalid(
1262 format!("{path}.unavailable_reason"),
1263 "a non-finite mesh-bind product requires its typed unavailable reason",
1264 ));
1265 }
1266 }
1267 }
1268 }
1269 Ok(())
1270}
1271
1272fn validate_derived_matrix(
1273 matrix: &SkinDerivedMatrixMeasurements,
1274 path: &str,
1275 finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1276 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1277) -> Result<(), MeasurementContractError> {
1278 if let Some(source) = &matrix.source_inverse_bind_matrix {
1279 finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1280 }
1281 if let Some(quality) = matrix.inversion_quality {
1282 let value = quality.reciprocal_condition_number_inf;
1283 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1284 return Err(invalid(
1285 format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1286 "reciprocal condition number must be finite and between zero and one",
1287 ));
1288 }
1289 }
1290 match (
1291 &matrix.matrix,
1292 matrix.linear.as_ref(),
1293 matrix.unavailable_reason,
1294 ) {
1295 (Some(matrix), Some(linear), None) => {
1296 finite_matrix(matrix, &format!("{path}.matrix"))?;
1297 validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1298 if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1299 return Err(invalid(
1300 format!("{path}.linear"),
1301 "linear facts must be derived from the available matrix",
1302 ));
1303 }
1304 }
1305 (None, None, Some(_)) => {}
1306 (Some(_), Some(_), Some(_)) => {
1307 return Err(invalid(
1308 path.into(),
1309 "an available derived matrix cannot have an unavailable reason",
1310 ));
1311 }
1312 _ => {
1313 return Err(invalid(
1314 path.into(),
1315 "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1316 ));
1317 }
1318 }
1319 Ok(())
1320}
1321
1322fn validate_material_resources(
1323 assets: &AssetMeasurements,
1324 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1325) -> Result<(), MeasurementContractError> {
1326 let absent = assets.material_definitions.is_empty()
1327 && assets.textures.is_empty()
1328 && assets.images.is_empty();
1329 if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1330 return Err(invalid(
1331 "material_resource_coverage".into(),
1332 "unavailable resource coverage requires empty material, texture, and image arrays",
1333 ));
1334 }
1335
1336 for (offset, material) in assets.material_definitions.iter().enumerate() {
1337 if material.material_index != offset {
1338 return Err(invalid(
1339 format!("material_definitions[{offset}].material_index"),
1340 "material_index must be contiguous and match source order",
1341 ));
1342 }
1343 let mut previous_slot = None;
1344 for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1345 if binding.texture_index >= assets.textures.len() {
1346 return Err(invalid(
1347 format!(
1348 "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1349 ),
1350 "texture_index must reference a source texture",
1351 ));
1352 }
1353 if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1354 return Err(invalid(
1355 format!(
1356 "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1357 ),
1358 "texture bindings must be strictly ordered by slot and unique",
1359 ));
1360 }
1361 previous_slot = Some(binding.slot);
1362 }
1363 }
1364 for (offset, texture) in assets.textures.iter().enumerate() {
1365 if texture.texture_index != offset {
1366 return Err(invalid(
1367 format!("textures[{offset}].texture_index"),
1368 "texture_index must be contiguous and match source order",
1369 ));
1370 }
1371 if texture.image_index >= assets.images.len() {
1372 return Err(invalid(
1373 format!("textures[{offset}].image_index"),
1374 "image_index must reference a source image",
1375 ));
1376 }
1377 }
1378 for (offset, image) in assets.images.iter().enumerate() {
1379 validate_image_measurement(image, offset, invalid)?;
1380 }
1381 Ok(())
1382}
1383
1384fn validate_image_measurement(
1385 image: &ImageMeasurements,
1386 offset: usize,
1387 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1388) -> Result<(), MeasurementContractError> {
1389 if image.image_index != offset {
1390 return Err(invalid(
1391 format!("images[{offset}].image_index"),
1392 "image_index must be contiguous and match source order",
1393 ));
1394 }
1395 let available = [
1396 image.width.is_some(),
1397 image.height.is_some(),
1398 image.channel_count.is_some(),
1399 image.decoded_color_type.is_some(),
1400 ];
1401 match (
1402 available.into_iter().all(|value| value),
1403 image.unavailable_reason,
1404 ) {
1405 (true, None) => {
1406 let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1407 image.width,
1408 image.height,
1409 image.channel_count,
1410 image.decoded_color_type,
1411 ) else {
1412 return Err(invalid(
1413 format!("images[{offset}]"),
1414 "available image metadata must include width, height, channel_count, and decoded_color_type",
1415 ));
1416 };
1417 if width == 0 || height == 0 {
1418 return Err(invalid(
1419 format!("images[{offset}]"),
1420 "available image dimensions must be greater than zero",
1421 ));
1422 }
1423 if channel_count != color_type_channel_count(decoded_color_type) {
1424 return Err(invalid(
1425 format!("images[{offset}].channel_count"),
1426 "channel_count must match decoded_color_type",
1427 ));
1428 }
1429 if image.detected_container.is_none() {
1430 return Err(invalid(
1431 format!("images[{offset}].detected_container"),
1432 "available image metadata requires a detected_container",
1433 ));
1434 }
1435 }
1436 (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1437 (true, Some(_)) => {
1438 return Err(invalid(
1439 format!("images[{offset}]"),
1440 "available image metadata cannot have an unavailable_reason",
1441 ));
1442 }
1443 (false, None) if available.into_iter().all(|value| !value) => {
1444 return Err(invalid(
1445 format!("images[{offset}]"),
1446 "missing image metadata requires an unavailable_reason",
1447 ));
1448 }
1449 (false, _) => {
1450 return Err(invalid(
1451 format!("images[{offset}]"),
1452 "available image metadata must include width, height, channel_count, and decoded_color_type",
1453 ));
1454 }
1455 }
1456 match image.unavailable_reason {
1457 Some(crate::model::ImageUnavailableReason::DecodeFailed)
1458 if image.detected_container.is_none() =>
1459 {
1460 return Err(invalid(
1461 format!("images[{offset}].detected_container"),
1462 "decode_failed requires a detected_container",
1463 ));
1464 }
1465 Some(
1466 crate::model::ImageUnavailableReason::SourceUnavailable
1467 | crate::model::ImageUnavailableReason::InvalidDataUri
1468 | crate::model::ImageUnavailableReason::UnsupportedContainer,
1469 ) if image.detected_container.is_some() => {
1470 return Err(invalid(
1471 format!("images[{offset}].detected_container"),
1472 "this unavailable_reason cannot have a detected_container",
1473 ));
1474 }
1475 _ => {}
1476 }
1477 Ok(())
1478}
1479
1480fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
1481 match color_type {
1482 DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
1483 DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
1484 DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
1485 DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
1486 }
1487}
1488
1489#[derive(Debug, Deserialize)]
1497pub struct MeasurementReportInput {
1498 schema_version: Option<u32>,
1499 schema: Option<String>,
1500 command: Option<String>,
1501 files: Option<Vec<MeasurementFileInput>>,
1502}
1503
1504#[derive(Debug, Deserialize)]
1505struct MeasurementFileInput {
1506 path: Option<String>,
1507 input: Option<InputIdentityInput>,
1508 measurements: Option<MeasurementPayloadInput>,
1509}
1510
1511#[derive(Debug, Deserialize)]
1512struct InputIdentityInput {
1513 sha256: Option<String>,
1514 bytes: Option<u64>,
1515}
1516
1517#[derive(Debug, Deserialize)]
1518#[serde(untagged)]
1519enum SkeletonNodeMeasurementInput {
1520 Current(Box<crate::measure::SkeletonNodeMeasurements>),
1521 Earlier {
1522 #[serde(rename = "node_index")]
1523 _node_index: usize,
1524 },
1525}
1526
1527#[derive(Debug, Deserialize)]
1528#[serde(untagged)]
1529enum SkinMeasurementInput {
1530 Current(Box<crate::measure::SkinMeasurements>),
1531 Earlier {
1532 #[serde(rename = "skin_index")]
1533 _skin_index: usize,
1534 },
1535}
1536
1537#[derive(Debug, Deserialize)]
1538struct MeasurementPayloadInput {
1539 schema_version: Option<u32>,
1540 schema: Option<String>,
1541 clips: Option<BTreeMap<String, ClipMeasurements>>,
1542 material_resource_coverage: Option<MaterialResourceCoverage>,
1543 material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
1544 textures: Option<Vec<TextureMeasurements>>,
1545 images: Option<Vec<ImageMeasurements>>,
1546 skeleton_source_coverage: Option<SourceSkeletonCoverage>,
1547 skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
1548 skins: Option<Vec<SkinMeasurementInput>>,
1549 mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
1550 node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
1551 scenes: Option<Vec<crate::measure::SceneMeasurements>>,
1552 default_scene_index: Option<usize>,
1553}
1554
1555#[derive(Debug, Clone)]
1561pub struct MeasurementReportFile {
1562 path: String,
1563 input: InputIdentity,
1564 measurements: MeasurementContract,
1565}
1566
1567impl MeasurementReportFile {
1568 pub fn path(&self) -> &str {
1570 &self.path
1571 }
1572
1573 pub fn input(&self) -> &InputIdentity {
1575 &self.input
1576 }
1577
1578 pub fn measurements(&self) -> &MeasurementContract {
1580 &self.measurements
1581 }
1582
1583 pub fn into_measurements(self) -> MeasurementContract {
1585 self.measurements
1586 }
1587}
1588
1589#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1591#[non_exhaustive]
1592pub enum MeasurementReportError {
1593 #[error("report envelope has no `schema_version`")]
1595 MissingOutputVersion,
1596 #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
1598 UnsupportedOutputVersion {
1599 found: u32,
1601 },
1602 #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
1604 WrongOutputIdentity,
1605 #[error("report envelope has no `command`")]
1607 MissingCommand,
1608 #[error("report command {command:?} does not carry measurement file records")]
1610 UnsupportedCommand {
1611 command: String,
1613 },
1614 #[error("report envelope has no `files` array")]
1616 MissingFiles,
1617 #[error("files[{file_index}] {source}")]
1619 File {
1620 file_index: usize,
1622 #[source]
1624 source: MeasurementFileError,
1625 },
1626}
1627
1628#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1630#[non_exhaustive]
1631pub enum MeasurementFileError {
1632 #[error("has no `path`")]
1634 MissingPath,
1635 #[error("has no `input`")]
1637 MissingInput,
1638 #[error("input has no `sha256`")]
1640 MissingSha256,
1641 #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
1643 InvalidSha256,
1644 #[error("input has no `bytes`")]
1646 MissingBytes,
1647 #[error("has no measurements")]
1649 MissingMeasurements,
1650 #[error("has no versioned measurement contract")]
1652 MissingMeasurementVersion,
1653 #[error(
1655 "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
1656 )]
1657 UnsupportedMeasurementVersion {
1658 found: u32,
1660 },
1661 #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
1663 WrongMeasurementIdentity,
1664 #[error("measurement contract has no `clips` map")]
1666 MissingClips,
1667 #[error("measurement contract has no `material_resource_coverage`")]
1669 MissingMaterialResourceCoverage,
1670 #[error("measurement contract has no `material_definitions` array")]
1672 MissingMaterialDefinitions,
1673 #[error("measurement contract has no `textures` array")]
1675 MissingTextures,
1676 #[error("measurement contract has no `images` array")]
1678 MissingImages,
1679 #[error("measurement contract has no `skeleton_source_coverage`")]
1681 MissingSkeletonSourceCoverage,
1682 #[error("measurement contract has no `skeleton_nodes` array")]
1684 MissingSkeletonNodes,
1685 #[error("measurement contract has no `skins` array")]
1687 MissingSkins,
1688 #[error("measurement contract has no `mesh_definitions` array")]
1690 MissingMeshDefinitions,
1691 #[error("measurement contract has no `node_instances` array")]
1693 MissingNodeInstances,
1694 #[error("measurement contract has no `scenes` array")]
1696 MissingScenes,
1697 #[error("has invalid measurements: {source}")]
1699 InvalidMeasurements {
1700 #[source]
1702 source: MeasurementContractError,
1703 },
1704}
1705
1706impl MeasurementReportError {
1707 pub fn file_index(&self) -> Option<usize> {
1711 match self {
1712 Self::File { file_index, .. } => Some(*file_index),
1713 _ => None,
1714 }
1715 }
1716
1717 fn file(file_index: usize, source: MeasurementFileError) -> Self {
1718 Self::File { file_index, source }
1719 }
1720}
1721
1722impl MeasurementReportInput {
1723 pub fn file_count(&self) -> Option<usize> {
1729 self.files.as_ref().map(Vec::len)
1730 }
1731
1732 pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
1743 match self.schema_version {
1744 Some(OUTPUT_SCHEMA_VERSION) => {}
1745 Some(found) => {
1746 return Err(MeasurementReportError::UnsupportedOutputVersion { found });
1747 }
1748 None => return Err(MeasurementReportError::MissingOutputVersion),
1749 }
1750 if self.schema.as_deref() != Some(OUTPUT_SCHEMA_ID) {
1751 return Err(MeasurementReportError::WrongOutputIdentity);
1752 }
1753 match self.command.as_deref() {
1754 Some("measure" | "lint") => {}
1755 Some(command) => {
1756 return Err(MeasurementReportError::UnsupportedCommand {
1757 command: command.to_owned(),
1758 });
1759 }
1760 None => return Err(MeasurementReportError::MissingCommand),
1761 }
1762 let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
1763 files
1764 .into_iter()
1765 .enumerate()
1766 .map(|(file_index, file)| {
1767 let path = file.path.ok_or_else(|| {
1768 MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
1769 })?;
1770 let input = file.input.ok_or_else(|| {
1771 MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
1772 })?;
1773 let sha256 = input.sha256.ok_or_else(|| {
1774 MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
1775 })?;
1776 if sha256.len() != 64
1777 || !sha256
1778 .bytes()
1779 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1780 {
1781 return Err(MeasurementReportError::file(
1782 file_index,
1783 MeasurementFileError::InvalidSha256,
1784 ));
1785 }
1786 let bytes = input.bytes.ok_or_else(|| {
1787 MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
1788 })?;
1789 let measurements = file.measurements.ok_or_else(|| {
1790 MeasurementReportError::file(
1791 file_index,
1792 MeasurementFileError::MissingMeasurements,
1793 )
1794 })?;
1795 match measurements.schema_version {
1796 Some(MEASUREMENTS_SCHEMA_VERSION) => {}
1797 Some(found) => {
1798 return Err(MeasurementReportError::file(
1799 file_index,
1800 MeasurementFileError::UnsupportedMeasurementVersion { found },
1801 ));
1802 }
1803 None => {
1804 return Err(MeasurementReportError::file(
1805 file_index,
1806 MeasurementFileError::MissingMeasurementVersion,
1807 ));
1808 }
1809 }
1810 if measurements.schema.as_deref() != Some(MEASUREMENTS_SCHEMA_ID) {
1811 return Err(MeasurementReportError::file(
1812 file_index,
1813 MeasurementFileError::WrongMeasurementIdentity,
1814 ));
1815 }
1816 let clips = measurements.clips.ok_or_else(|| {
1817 MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
1818 })?;
1819 let material_resource_coverage =
1820 measurements.material_resource_coverage.ok_or_else(|| {
1821 MeasurementReportError::file(
1822 file_index,
1823 MeasurementFileError::MissingMaterialResourceCoverage,
1824 )
1825 })?;
1826 let material_definitions = measurements.material_definitions.ok_or_else(|| {
1827 MeasurementReportError::file(
1828 file_index,
1829 MeasurementFileError::MissingMaterialDefinitions,
1830 )
1831 })?;
1832 let textures = measurements.textures.ok_or_else(|| {
1833 MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
1834 })?;
1835 let images = measurements.images.ok_or_else(|| {
1836 MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
1837 })?;
1838 let skeleton_source_coverage =
1839 measurements.skeleton_source_coverage.ok_or_else(|| {
1840 MeasurementReportError::file(
1841 file_index,
1842 MeasurementFileError::MissingSkeletonSourceCoverage,
1843 )
1844 })?;
1845 let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
1846 MeasurementReportError::file(
1847 file_index,
1848 MeasurementFileError::MissingSkeletonNodes,
1849 )
1850 })?;
1851 let skeleton_nodes = skeleton_nodes
1852 .into_iter()
1853 .enumerate()
1854 .map(|(offset, node)| match node {
1855 SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
1856 SkeletonNodeMeasurementInput::Earlier { .. } => {
1857 Err(MeasurementReportError::file(
1858 file_index,
1859 MeasurementFileError::InvalidMeasurements {
1860 source: MeasurementContractError::InvalidStructure {
1861 path: format!("skeleton_nodes[{offset}]"),
1862 reason: "uses a shape from an earlier measurement contract"
1863 .into(),
1864 },
1865 },
1866 ))
1867 }
1868 })
1869 .collect::<Result<Vec<_>, _>>()?;
1870 let skins = measurements.skins.ok_or_else(|| {
1871 MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
1872 })?;
1873 let skins = skins
1874 .into_iter()
1875 .enumerate()
1876 .map(|(offset, skin)| match skin {
1877 SkinMeasurementInput::Current(skin) => Ok(*skin),
1878 SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
1879 file_index,
1880 MeasurementFileError::InvalidMeasurements {
1881 source: MeasurementContractError::InvalidStructure {
1882 path: format!("skins[{offset}]"),
1883 reason: "uses a shape from an earlier measurement contract"
1884 .into(),
1885 },
1886 },
1887 )),
1888 })
1889 .collect::<Result<Vec<_>, _>>()?;
1890 let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
1891 MeasurementReportError::file(
1892 file_index,
1893 MeasurementFileError::MissingMeshDefinitions,
1894 )
1895 })?;
1896 let node_instances = measurements.node_instances.ok_or_else(|| {
1897 MeasurementReportError::file(
1898 file_index,
1899 MeasurementFileError::MissingNodeInstances,
1900 )
1901 })?;
1902 let scenes = measurements.scenes.ok_or_else(|| {
1903 MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
1904 })?;
1905 let assets = AssetMeasurements {
1906 material_resource_coverage,
1907 material_definitions,
1908 textures,
1909 images,
1910 skeleton_source_coverage,
1911 skeleton_nodes,
1912 skins,
1913 mesh_definitions,
1914 node_instances,
1915 scenes,
1916 default_scene_index: measurements.default_scene_index,
1917 };
1918 let measurements = MeasurementContract::new(clips, assets).map_err(|source| {
1919 MeasurementReportError::file(
1920 file_index,
1921 MeasurementFileError::InvalidMeasurements { source },
1922 )
1923 })?;
1924 Ok(MeasurementReportFile {
1925 path,
1926 input: InputIdentity { sha256, bytes },
1927 measurements,
1928 })
1929 })
1930 .collect()
1931 }
1932}
1933
1934#[cfg(test)]
1935mod measurement_report_input_tests {
1936 use super::*;
1937
1938 #[test]
1939 fn v11_nested_version_is_rejected_before_current_shape_decode() {
1940 let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
1941 "schema_version": OUTPUT_SCHEMA_VERSION,
1942 "schema": OUTPUT_SCHEMA_ID,
1943 "command": "measure",
1944 "files": [{
1945 "path": "measurements-v11.json",
1946 "input": { "sha256": "0".repeat(64), "bytes": 0 },
1947 "measurements": {
1948 "schema_version": 11,
1949 "schema": "urn:animsmith:schema:measurements:11",
1950 "skeleton_nodes": [{
1951 "node_index": 0,
1952 "scene_root_indices": [],
1953 "local_rest": {
1954 "kind": "trs",
1955 "translation_m": [0.0, 0.0, 0.0],
1956 "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
1957 "scale": [1.0, 1.0, 1.0]
1958 },
1959 "rest_world_matrix": [
1960 1.0, 0.0, 0.0, 0.0,
1961 0.0, 1.0, 0.0, 0.0,
1962 0.0, 0.0, 1.0, 0.0,
1963 0.0, 0.0, 0.0, 1.0
1964 ]
1965 }],
1966 "skins": [{ "skin_index": 0 }]
1967 }
1968 }]
1969 }))
1970 .expect("unsupported payload shapes remain decodable for version rejection");
1971
1972 assert!(matches!(
1973 report.into_files(),
1974 Err(MeasurementReportError::File {
1975 file_index: 0,
1976 source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
1977 })
1978 ));
1979 }
1980
1981 #[test]
1982 fn recovered_payloads_run_measurement_contract_validation() {
1983 let file =
1989 |path: &str,
1990 clips: BTreeMap<String, ClipMeasurements>,
1991 mesh_definitions: Vec<crate::measure::MeshDefinitionMeasurements>| {
1992 MeasurementFileInput {
1993 path: Some(path.into()),
1994 input: Some(InputIdentityInput {
1995 sha256: Some("0".repeat(64)),
1996 bytes: Some(0),
1997 }),
1998 measurements: Some(MeasurementPayloadInput {
1999 schema_version: Some(MEASUREMENTS_SCHEMA_VERSION),
2000 schema: Some(MEASUREMENTS_SCHEMA_ID.into()),
2001 clips: Some(clips),
2002 material_resource_coverage: Some(MaterialResourceCoverage::Unavailable),
2003 material_definitions: Some(Vec::new()),
2004 textures: Some(Vec::new()),
2005 images: Some(Vec::new()),
2006 skeleton_source_coverage: Some(SourceSkeletonCoverage::Unavailable),
2007 skeleton_nodes: Some(Vec::new()),
2008 skins: Some(Vec::new()),
2009 mesh_definitions: Some(mesh_definitions),
2010 node_instances: Some(Vec::new()),
2011 scenes: Some(Vec::new()),
2012 default_scene_index: None,
2013 }),
2014 }
2015 };
2016 let report = |files| MeasurementReportInput {
2017 schema_version: Some(OUTPUT_SCHEMA_VERSION),
2018 schema: Some(OUTPUT_SCHEMA_ID.into()),
2019 command: Some("measure".into()),
2020 files: Some(files),
2021 };
2022 let invalid_clip = || ClipMeasurements {
2023 duration_s: f64::NAN,
2024 frame_count: 1,
2025 animated_bones: Vec::new(),
2026 bone_rotation_range_deg: BTreeMap::new(),
2027 loop_continuity: None,
2028 loop_endpoint_mode: None,
2029 frame_grid: None,
2030 loop_seam_ratio: None,
2031 gait: None,
2032 speed_mps: None,
2033 };
2034 let invalid_mesh = || crate::measure::MeshDefinitionMeasurements {
2035 mesh_index: 0,
2036 name: "mesh".into(),
2037 vertex_count: 1,
2038 geometry_aabb: None,
2039 geometry_centroid: None,
2040 max_joints_per_vertex: 1,
2041 weight_sum_min: Some(f64::NAN),
2042 weight_sum_max: Some(1.0),
2043 additional_influence_sets: Vec::new(),
2044 };
2045 let valid = || file("valid.glb", BTreeMap::new(), Vec::new());
2046 let cases = [
2047 (
2048 report(vec![file(
2049 "invalid-clip.glb",
2050 BTreeMap::from([("walk".into(), invalid_clip())]),
2051 Vec::new(),
2052 )]),
2053 MeasurementReportError::File {
2054 file_index: 0,
2055 source: MeasurementFileError::InvalidMeasurements {
2056 source: MeasurementContractError::NonFiniteValue {
2057 path: "clips[\"walk\"].duration_s".into(),
2058 },
2059 },
2060 },
2061 "files[0] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
2062 0,
2063 ),
2064 (
2065 report(vec![file(
2066 "invalid-mesh.glb",
2067 BTreeMap::new(),
2068 vec![invalid_mesh()],
2069 )]),
2070 MeasurementReportError::File {
2071 file_index: 0,
2072 source: MeasurementFileError::InvalidMeasurements {
2073 source: MeasurementContractError::NonFiniteValue {
2074 path: "mesh_definitions[0].weight_sum_min".into(),
2075 },
2076 },
2077 },
2078 "files[0] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
2079 0,
2080 ),
2081 (
2082 report(vec![
2083 valid(),
2084 file(
2085 "invalid-clip.glb",
2086 BTreeMap::from([("walk".into(), invalid_clip())]),
2087 Vec::new(),
2088 ),
2089 ]),
2090 MeasurementReportError::File {
2091 file_index: 1,
2092 source: MeasurementFileError::InvalidMeasurements {
2093 source: MeasurementContractError::NonFiniteValue {
2094 path: "clips[\"walk\"].duration_s".into(),
2095 },
2096 },
2097 },
2098 "files[1] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
2099 1,
2100 ),
2101 (
2102 report(vec![
2103 valid(),
2104 file("invalid-mesh.glb", BTreeMap::new(), vec![invalid_mesh()]),
2105 ]),
2106 MeasurementReportError::File {
2107 file_index: 1,
2108 source: MeasurementFileError::InvalidMeasurements {
2109 source: MeasurementContractError::NonFiniteValue {
2110 path: "mesh_definitions[0].weight_sum_min".into(),
2111 },
2112 },
2113 },
2114 "files[1] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
2115 1,
2116 ),
2117 ];
2118
2119 for (input, expected, expected_display, expected_file_index) in cases {
2120 let error = input
2121 .into_files()
2122 .expect_err("recovered evidence must be validated");
2123 assert_eq!(error, expected);
2124 assert_eq!(error.file_index(), Some(expected_file_index));
2125 assert_eq!(error.to_string(), expected_display);
2126 }
2127 }
2128}
2129
2130#[derive(Debug, Clone, Serialize)]
2131struct FileEvidence {
2132 path: String,
2133 input: InputIdentity,
2134 rig: RigInfo,
2135 measurements: MeasurementContract,
2136}
2137
2138impl FileEvidence {
2139 fn new(
2140 path: impl Into<String>,
2141 input: InputIdentity,
2142 rig: RigInfo,
2143 measurements: MeasurementContract,
2144 ) -> Self {
2145 Self {
2146 path: path.into(),
2147 input,
2148 rig,
2149 measurements,
2150 }
2151 }
2152}
2153
2154#[derive(Debug, Clone, Serialize)]
2156pub struct MeasureFileReport {
2157 #[serde(flatten)]
2158 evidence: FileEvidence,
2159}
2160
2161impl MeasureFileReport {
2162 pub fn new(
2164 path: impl Into<String>,
2165 input: InputIdentity,
2166 rig: RigInfo,
2167 measurements: MeasurementContract,
2168 ) -> Self {
2169 Self {
2170 evidence: FileEvidence::new(path, input, rig, measurements),
2171 }
2172 }
2173
2174 pub fn path(&self) -> &str {
2176 &self.evidence.path
2177 }
2178
2179 pub fn input(&self) -> &InputIdentity {
2181 &self.evidence.input
2182 }
2183
2184 pub fn measurements(&self) -> &MeasurementContract {
2186 &self.evidence.measurements
2187 }
2188}
2189
2190#[derive(Debug, Clone, Serialize)]
2192pub struct LintFileReport {
2193 #[serde(flatten)]
2194 evidence: FileEvidence,
2195 checks: Vec<CheckEvaluation>,
2196}
2197
2198impl LintFileReport {
2199 pub fn new(
2201 path: impl Into<String>,
2202 input: InputIdentity,
2203 rig: RigInfo,
2204 checks: Vec<CheckEvaluation>,
2205 measurements: MeasurementContract,
2206 ) -> Self {
2207 Self {
2208 evidence: FileEvidence::new(path, input, rig, measurements),
2209 checks,
2210 }
2211 }
2212
2213 pub fn path(&self) -> &str {
2215 &self.evidence.path
2216 }
2217
2218 pub fn input(&self) -> &InputIdentity {
2220 &self.evidence.input
2221 }
2222
2223 pub fn checks(&self) -> &[CheckEvaluation] {
2225 &self.checks
2226 }
2227
2228 pub fn measurements(&self) -> &MeasurementContract {
2230 &self.evidence.measurements
2231 }
2232}
2233
2234#[derive(Debug, Clone, Serialize)]
2235struct EnvelopeHeader {
2236 schema_version: u32,
2237 schema: &'static str,
2238 tool: ToolInfo,
2239 command: &'static str,
2240}
2241
2242impl EnvelopeHeader {
2243 fn new(tool: ToolInfo, command: &'static str) -> Self {
2244 Self {
2245 schema_version: OUTPUT_SCHEMA_VERSION,
2246 schema: OUTPUT_SCHEMA_ID,
2247 tool,
2248 command,
2249 }
2250 }
2251}
2252
2253#[derive(Debug, Clone, Serialize)]
2254struct MeasureSummary {
2255 files: usize,
2256}
2257
2258#[derive(Debug, Clone, Default, Serialize)]
2259struct FindingSummary {
2260 error: usize,
2261 warning: usize,
2262 note: usize,
2263}
2264
2265impl FindingSummary {
2266 fn add(&mut self, severity: Severity) {
2267 match severity {
2268 Severity::Error => self.error += 1,
2269 Severity::Warning => self.warning += 1,
2270 Severity::Note => self.note += 1,
2271 }
2272 }
2273}
2274
2275#[derive(Debug, Clone, Default, Serialize)]
2276struct SelectionSummary {
2277 selected: usize,
2278 unselected: usize,
2279}
2280
2281#[derive(Debug, Clone, Default, Serialize)]
2282struct ConfigurationSummary {
2283 enabled: usize,
2284 disabled: usize,
2285}
2286
2287#[derive(Debug, Clone, Default, Serialize)]
2288struct ApplicabilitySummary {
2289 applicable: usize,
2290 not_applicable: usize,
2291}
2292
2293#[derive(Debug, Clone, Default, Serialize)]
2294struct EvaluationStateSummary {
2295 complete: usize,
2296 partial: usize,
2297 not_evaluated: usize,
2298}
2299
2300#[derive(Debug, Clone, Default, Serialize)]
2301struct CheckSummary {
2302 total: usize,
2303 selection: SelectionSummary,
2304 configuration: ConfigurationSummary,
2305 applicability: ApplicabilitySummary,
2306 evaluation: EvaluationStateSummary,
2307 gaps: usize,
2308}
2309
2310#[derive(Debug, Clone, Serialize)]
2311struct LintSummary {
2312 files: usize,
2313 findings: FindingSummary,
2314 checks: CheckSummary,
2315}
2316
2317#[derive(Debug, Clone, Serialize)]
2319pub struct MeasureEnvelope {
2320 #[serde(flatten)]
2321 header: EnvelopeHeader,
2322 summary: MeasureSummary,
2323 files: Vec<MeasureFileReport>,
2324}
2325
2326impl MeasureEnvelope {
2327 pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Self {
2329 Self {
2330 header: EnvelopeHeader::new(tool, "measure"),
2331 summary: MeasureSummary { files: files.len() },
2332 files,
2333 }
2334 }
2335}
2336
2337#[derive(Debug, Clone, Serialize)]
2339pub struct LintEnvelope {
2340 #[serde(flatten)]
2341 header: EnvelopeHeader,
2342 summary: LintSummary,
2343 files: Vec<LintFileReport>,
2344}
2345
2346impl LintEnvelope {
2347 pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Self {
2350 let mut findings = FindingSummary::default();
2351 let mut checks = CheckSummary::default();
2352 for file in &files {
2353 for check in file.checks() {
2354 checks.total += 1;
2355 for finding in check.findings() {
2356 findings.add(finding.severity);
2357 }
2358 match check.selection() {
2359 SelectionState::Selected => checks.selection.selected += 1,
2360 SelectionState::Unselected => checks.selection.unselected += 1,
2361 }
2362 match check.configuration() {
2363 ConfigurationState::Enabled => checks.configuration.enabled += 1,
2364 ConfigurationState::Disabled => checks.configuration.disabled += 1,
2365 }
2366 match check.applicability() {
2367 Applicability::Applicable => checks.applicability.applicable += 1,
2368 Applicability::NotApplicable => checks.applicability.not_applicable += 1,
2369 }
2370 match check.evaluation() {
2371 EvaluationState::Complete => checks.evaluation.complete += 1,
2372 EvaluationState::Partial => checks.evaluation.partial += 1,
2373 EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
2374 }
2375 checks.gaps += check.gaps().len();
2376 }
2377 }
2378 Self {
2379 header: EnvelopeHeader::new(tool, "lint"),
2380 summary: LintSummary {
2381 files: files.len(),
2382 findings,
2383 checks,
2384 },
2385 files,
2386 }
2387 }
2388}
2389
2390#[derive(Debug, Clone, Serialize)]
2391struct DiffInputs {
2392 before: String,
2393 after: String,
2394}
2395
2396#[derive(Debug, Clone, Serialize)]
2397struct DiffSummary {
2398 deltas: usize,
2399}
2400
2401#[derive(Debug, Serialize)]
2403pub struct DiffEnvelope {
2404 #[serde(flatten)]
2405 header: EnvelopeHeader,
2406 inputs: DiffInputs,
2407 summary: DiffSummary,
2408 deltas: Vec<MetricDelta>,
2409}
2410
2411impl DiffEnvelope {
2412 pub fn new(
2414 tool: ToolInfo,
2415 before: impl Into<String>,
2416 after: impl Into<String>,
2417 deltas: Vec<MetricDelta>,
2418 ) -> Self {
2419 Self {
2420 header: EnvelopeHeader::new(tool, "diff"),
2421 inputs: DiffInputs {
2422 before: before.into(),
2423 after: after.into(),
2424 },
2425 summary: DiffSummary {
2426 deltas: deltas.len(),
2427 },
2428 deltas,
2429 }
2430 }
2431}