1use std::collections::{BTreeMap, BTreeSet};
8use std::fmt::Write as _;
9use std::io::Read;
10
11use glam::Mat4;
12use serde::de::{DeserializeSeed, EnumAccess, MapAccess, SeqAccess, VariantAccess, Visitor};
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::value::RawValue;
15use sha2::{Digest, Sha256};
16
17use crate::diff::MetricDelta;
18use crate::evaluation::{
19 Applicability, CheckEvaluation, CheckEvaluationGapRef, CheckEvaluationValidationInput,
20 ConfigurationState, EvaluationState, SelectionState, validate_and_derive_check_evaluation,
21};
22use crate::measure::{
23 Aabb, AssetMeasurements, ClipMeasurements, ImageMeasurements, LinearTransformClassification,
24 LinearTransformMeasurements, MaterialDefinitionMeasurements, MeasurementAvailability,
25 SkeletonNodeLocalRestMeasurements, SkeletonRestWorldMatrixUnavailableReason,
26 SkinDerivedMatrixMeasurements, SkinDerivedMatrixUnavailableReason, TextureMeasurements,
27 assess_inverse_bind, measure_linear_transform, summarize_skin_bind_linear,
28};
29use crate::metrics::canonical_net_yaw_deg;
30use crate::model::{
31 DecodedImageColorType, MaterialResourceCoverage, SourceInverseBindAccessorStatus,
32 SourceSkeletonCoverage,
33};
34use crate::prediction::{
35 EnginePredictionFacetStateV1, PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
36 PREDICTION_V1_MAX_FACETS_PER_FILE, PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
37 PredictionContractError, PredictionDecodeError, PredictionProvenanceV1,
38 decode_engine_prediction_v1, decode_prediction_provenance_v1,
39 validate_measurement_references_batch,
40};
41use crate::profile::ResolvedRoles;
42use crate::{Document, Severity};
43
44pub const OUTPUT_SCHEMA_VERSION: u32 = 10;
46pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:10";
48pub const OUTPUT_V10_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
50pub const OUTPUT_V10_MAX_FILES: usize = 4_096;
52pub const OUTPUT_V10_MAX_CHECKS_PER_FILE: usize = 4_096;
54pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 15;
56pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:15";
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct ToolSource {
62 revision: Option<String>,
63 dirty: Option<bool>,
64}
65
66impl ToolSource {
67 pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
74 let revision = revision.filter(|revision| {
75 revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
76 });
77 Self { revision, dirty }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct ToolInfo {
84 name: &'static str,
85 version: &'static str,
86 source: ToolSource,
87}
88
89impl ToolInfo {
90 pub fn animsmith(source: ToolSource) -> Self {
93 Self {
94 name: "animsmith",
95 version: env!("CARGO_PKG_VERSION"),
96 source,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct InputIdentity {
107 sha256: String,
108 bytes: u64,
109}
110
111#[must_use]
117pub fn sha256_hex(bytes: &[u8]) -> String {
118 sha256_digest_hex(Sha256::digest(bytes).into())
119}
120
121fn sha256_digest_hex(digest: [u8; 32]) -> String {
122 let mut hex = String::with_capacity(64);
123 for byte in digest {
124 let _ = write!(hex, "{byte:02x}");
125 }
126 hex
127}
128
129impl InputIdentity {
130 pub fn from_bytes(bytes: &[u8]) -> Self {
132 Self {
133 sha256: sha256_hex(bytes),
134 bytes: bytes.len() as u64,
135 }
136 }
137
138 pub fn from_sha256_digest(digest: [u8; 32], bytes: u64) -> Self {
143 Self {
144 sha256: sha256_digest_hex(digest),
145 bytes,
146 }
147 }
148
149 pub fn sha256(&self) -> &str {
151 &self.sha256
152 }
153
154 pub fn bytes(&self) -> u64 {
156 self.bytes
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct RigInfo {
163 profile: String,
164 resolved_roles: BTreeMap<&'static str, String>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169#[non_exhaustive]
170pub enum RigInfoError {
171 #[error(
173 "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
174 )]
175 InvalidBoneId {
176 role: &'static str,
178 bone: usize,
180 bone_count: usize,
182 },
183 #[error(
185 "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
186 )]
187 BoneNameMismatch {
188 role: &'static str,
190 bone: usize,
192 expected: String,
194 found: String,
196 },
197}
198
199impl RigInfo {
200 pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
209 let resolved_roles = roles
210 .iter_with_names()
211 .map(|(role, bone, expected_name)| {
212 let name = doc
213 .skeleton
214 .bones
215 .get(bone)
216 .ok_or(RigInfoError::InvalidBoneId {
217 role: role.as_str(),
218 bone,
219 bone_count: doc.skeleton.bones.len(),
220 })?;
221 if name.name != expected_name {
222 return Err(RigInfoError::BoneNameMismatch {
223 role: role.as_str(),
224 bone,
225 expected: expected_name.to_owned(),
226 found: name.name.clone(),
227 });
228 }
229 Ok((role.as_str(), name.name.clone()))
230 })
231 .collect::<Result<_, _>>()?;
232 Ok(Self {
233 profile: roles.profile.clone(),
234 resolved_roles,
235 })
236 }
237}
238
239#[derive(Debug, Clone, Serialize)]
242pub struct MeasurementContract {
243 schema_version: u32,
244 schema: &'static str,
245 clips: BTreeMap<String, ClipMeasurements>,
246 #[serde(flatten)]
247 assets: AssetMeasurements,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
252#[non_exhaustive]
253pub enum MeasurementContractError {
254 #[error("measurement value {path} must be finite")]
256 NonFiniteValue {
257 path: String,
259 },
260 #[error("measurement structure {path} is invalid: {reason}")]
262 InvalidStructure {
263 path: String,
265 reason: String,
267 },
268}
269
270impl MeasurementContract {
271 pub fn new(
278 clips: BTreeMap<String, ClipMeasurements>,
279 assets: AssetMeasurements,
280 ) -> Result<Self, MeasurementContractError> {
281 validate_measurements(&clips, &assets)?;
282 Ok(Self {
283 schema_version: MEASUREMENTS_SCHEMA_VERSION,
284 schema: MEASUREMENTS_SCHEMA_ID,
285 clips,
286 assets,
287 })
288 }
289
290 pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
292 &self.clips
293 }
294
295 pub fn assets(&self) -> &AssetMeasurements {
297 &self.assets
298 }
299
300 pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
302 (self.clips, self.assets)
303 }
304}
305
306fn validate_measurements(
307 clips: &BTreeMap<String, ClipMeasurements>,
308 assets: &AssetMeasurements,
309) -> Result<(), MeasurementContractError> {
310 let finite = |value: f64, path: String| {
311 value
312 .is_finite()
313 .then_some(())
314 .ok_or(MeasurementContractError::NonFiniteValue { path })
315 };
316 let permits_roundoff = |observed: f64, lower_bound: f64| {
317 let tolerance = 1.0e-9 * observed.abs().max(lower_bound.abs()).max(1.0);
318 observed + tolerance >= lower_bound
319 };
320 let check_availability = |value_present: bool,
321 availability: MeasurementAvailability,
322 path: String| {
323 match (value_present, availability) {
324 (true, MeasurementAvailability::Measured) => Ok(()),
325 (
326 false,
327 MeasurementAvailability::NotApplicable | MeasurementAvailability::Unavailable,
328 ) => Ok(()),
329 _ => Err(MeasurementContractError::InvalidStructure {
330 path,
331 reason: "value presence must match availability status".into(),
332 }),
333 }
334 };
335 for (clip_name, clip) in clips {
336 finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
337 let mut previous_bone_index = None;
338 let mut covered_bone_names = BTreeSet::new();
339 for (offset, bone) in clip.bone_channels.iter().enumerate() {
340 let path = format!("clips[{clip_name:?}].bone_channels[{offset}]");
341 if previous_bone_index.is_some_and(|previous| previous >= bone.bone_index) {
342 return Err(MeasurementContractError::InvalidStructure {
343 path: format!("{path}.bone_index"),
344 reason: "bone channel entries must use strictly increasing unique bone indices"
345 .into(),
346 });
347 }
348 previous_bone_index = Some(bone.bone_index);
349 if bone.properties.is_empty() {
350 return Err(MeasurementContractError::InvalidStructure {
351 path: format!("{path}.properties"),
352 reason: "bone channel coverage must contain at least one property".into(),
353 });
354 }
355 if bone
356 .properties
357 .windows(2)
358 .any(|properties| properties[0] >= properties[1])
359 {
360 return Err(MeasurementContractError::InvalidStructure {
361 path: format!("{path}.properties"),
362 reason:
363 "channel properties must be unique and ordered translation, rotation, scale"
364 .into(),
365 });
366 }
367 covered_bone_names.insert(bone.bone_name.clone());
368 }
369 let expected_animated_bones: Vec<_> = covered_bone_names.into_iter().collect();
370 if clip.animated_bones != expected_animated_bones {
371 return Err(MeasurementContractError::InvalidStructure {
372 path: format!("clips[{clip_name:?}].animated_bones"),
373 reason: "animated_bones must equal the sorted unique bone names in bone_channels"
374 .into(),
375 });
376 }
377 for (bone, value) in &clip.bone_rotation_range_deg {
378 if clip.animated_bones.binary_search(bone).is_err() {
379 return Err(MeasurementContractError::InvalidStructure {
380 path: format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
381 reason: "rotation-range bones must be present in animated_bones".into(),
382 });
383 }
384 finite(
385 *value,
386 format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
387 )?;
388 }
389 check_availability(
390 clip.loop_continuity.is_some(),
391 clip.loop_continuity_availability,
392 format!("clips[{clip_name:?}].loop_continuity"),
393 )?;
394 check_availability(
395 clip.loop_endpoint_mode.is_some(),
396 clip.loop_endpoint_mode_availability,
397 format!("clips[{clip_name:?}].loop_endpoint_mode"),
398 )?;
399 check_availability(
400 clip.frame_grid.is_some(),
401 clip.frame_grid_availability,
402 format!("clips[{clip_name:?}].frame_grid"),
403 )?;
404 check_availability(
405 clip.loop_seam_ratio.is_some(),
406 clip.loop_seam_ratio_availability,
407 format!("clips[{clip_name:?}].loop_seam_ratio"),
408 )?;
409 check_availability(
410 clip.gait.is_some(),
411 clip.gait_availability,
412 format!("clips[{clip_name:?}].gait"),
413 )?;
414 check_availability(
415 clip.root_trajectory.is_some(),
416 clip.root_trajectory_availability,
417 format!("clips[{clip_name:?}].root_trajectory"),
418 )?;
419 check_availability(
420 clip.speed_mps.is_some(),
421 clip.speed_mps_availability,
422 format!("clips[{clip_name:?}].speed_mps"),
423 )?;
424 if let Some(gait) = &clip.gait {
425 check_availability(
426 gait.phase.is_some(),
427 gait.phase_availability,
428 format!("clips[{clip_name:?}].gait.phase"),
429 )?;
430 }
431 if let Some(trajectory) = &clip.root_trajectory {
432 let path = format!("clips[{clip_name:?}].root_trajectory");
433 check_availability(
434 trajectory.translation.is_some(),
435 trajectory.translation_availability,
436 format!("{path}.translation"),
437 )?;
438 check_availability(
439 trajectory.yaw.is_some(),
440 trajectory.yaw_availability,
441 format!("{path}.yaw"),
442 )?;
443 if trajectory.translation_availability == MeasurementAvailability::NotApplicable {
444 return Err(MeasurementContractError::InvalidStructure {
445 path: format!("{path}.translation_availability"),
446 reason:
447 "translation remains applicable when a root-trajectory bone is selected"
448 .into(),
449 });
450 }
451 if trajectory.yaw_availability == MeasurementAvailability::NotApplicable {
452 return Err(MeasurementContractError::InvalidStructure {
453 path: format!("{path}.yaw_availability"),
454 reason: "yaw remains applicable when a root-trajectory bone is selected".into(),
455 });
456 }
457 if let Some(translation) = trajectory.translation {
458 for (field, value) in [
459 (
460 "horizontal_displacement_x_m",
461 translation.horizontal_displacement_x_m,
462 ),
463 (
464 "horizontal_displacement_z_m",
465 translation.horizontal_displacement_z_m,
466 ),
467 ("horizontal_travel_m", translation.horizontal_travel_m),
468 (
469 "vertical_displacement_m",
470 translation.vertical_displacement_m,
471 ),
472 (
473 "vertical_min_displacement_m",
474 translation.vertical_min_displacement_m,
475 ),
476 (
477 "vertical_max_displacement_m",
478 translation.vertical_max_displacement_m,
479 ),
480 ] {
481 finite(value, format!("{path}.translation.{field}"))?;
482 }
483 if translation.horizontal_travel_m < 0.0 {
484 return Err(MeasurementContractError::InvalidStructure {
485 path: format!("{path}.translation.horizontal_travel_m"),
486 reason: "sampled horizontal travel must be non-negative".into(),
487 });
488 }
489 let horizontal_displacement_m = translation
490 .horizontal_displacement_x_m
491 .hypot(translation.horizontal_displacement_z_m);
492 if !permits_roundoff(translation.horizontal_travel_m, horizontal_displacement_m) {
493 return Err(MeasurementContractError::InvalidStructure {
494 path: format!("{path}.translation.horizontal_travel_m"),
495 reason: "sampled horizontal travel must contain endpoint displacement"
496 .into(),
497 });
498 }
499 if translation.vertical_min_displacement_m > 0.0
500 || translation.vertical_max_displacement_m < 0.0
501 || translation.vertical_displacement_m < translation.vertical_min_displacement_m
502 || translation.vertical_displacement_m > translation.vertical_max_displacement_m
503 {
504 return Err(MeasurementContractError::InvalidStructure {
505 path: format!("{path}.translation"),
506 reason: "vertical extrema must include zero and the endpoint displacement"
507 .into(),
508 });
509 }
510 }
511 if let Some(yaw) = trajectory.yaw {
512 finite(yaw.net_yaw_deg, format!("{path}.yaw.net_yaw_deg"))?;
513 finite(
514 yaw.unwrapped_yaw_deg,
515 format!("{path}.yaw.unwrapped_yaw_deg"),
516 )?;
517 finite(yaw.yaw_travel_deg, format!("{path}.yaw.yaw_travel_deg"))?;
518 if !(-180.0..=180.0).contains(&yaw.net_yaw_deg) {
519 return Err(MeasurementContractError::InvalidStructure {
520 path: format!("{path}.yaw.net_yaw_deg"),
521 reason: "net yaw must be in the inclusive range [-180, 180]".into(),
522 });
523 }
524 if yaw.net_yaw_deg != canonical_net_yaw_deg(yaw.unwrapped_yaw_deg) {
525 return Err(MeasurementContractError::InvalidStructure {
526 path: format!("{path}.yaw.net_yaw_deg"),
527 reason: "net yaw must be the canonical endpoint-equivalent unwrapped yaw"
528 .into(),
529 });
530 }
531 if yaw.yaw_travel_deg < 0.0 {
532 return Err(MeasurementContractError::InvalidStructure {
533 path: format!("{path}.yaw.yaw_travel_deg"),
534 reason: "sampled yaw travel must be non-negative".into(),
535 });
536 }
537 if !permits_roundoff(yaw.yaw_travel_deg, yaw.unwrapped_yaw_deg.abs()) {
538 return Err(MeasurementContractError::InvalidStructure {
539 path: format!("{path}.yaw.yaw_travel_deg"),
540 reason: "sampled yaw travel must contain signed unwrapped yaw".into(),
541 });
542 }
543 }
544 }
545 if let Some(loop_continuity) = &clip.loop_continuity {
546 if loop_continuity.bones.is_empty() {
547 return Err(MeasurementContractError::InvalidStructure {
548 path: format!("clips[{clip_name:?}].loop_continuity.bones"),
549 reason: "present loop-continuity evidence must contain at least one bone"
550 .into(),
551 });
552 }
553 for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
554 let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
555 if usize::try_from(bone.bone_index) != Ok(expected_index) {
556 return Err(MeasurementContractError::InvalidStructure {
557 path: format!("{path}.bone_index"),
558 reason: format!(
559 "expected skeleton-order index {expected_index}, found {}",
560 bone.bone_index
561 ),
562 });
563 }
564 for (field, value) in [
565 ("position_delta_m", bone.position_delta_m),
566 ("rotation_delta_deg", bone.rotation_delta_deg),
567 ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
568 (
569 "seam_angular_velocity_delta_degps",
570 bone.seam_angular_velocity_delta_degps,
571 ),
572 ] {
573 finite(value, format!("{path}.{field}"))?;
574 if value < 0.0 {
575 return Err(MeasurementContractError::InvalidStructure {
576 path: format!("{path}.{field}"),
577 reason: "loop-continuity deltas must be non-negative".into(),
578 });
579 }
580 }
581 }
582 }
583 if let Some(frame_grid) = &clip.frame_grid {
584 let path = format!("clips[{clip_name:?}].frame_grid");
585 finite(frame_grid.fps, format!("{path}.fps"))?;
586 if frame_grid.fps <= 0.0 {
587 return Err(MeasurementContractError::InvalidStructure {
588 path: format!("{path}.fps"),
589 reason: "declared frame-grid FPS must be positive".into(),
590 });
591 }
592 if frame_grid.frame_intervals == 0 {
593 return Err(MeasurementContractError::InvalidStructure {
594 path: format!("{path}.frame_intervals"),
595 reason: "declared frame-grid evidence must contain at least one interval"
596 .into(),
597 });
598 }
599 }
600 if let Some(value) = clip.loop_seam_ratio {
601 finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
602 }
603 if let Some(gait) = &clip.gait {
604 if let Some(value) = gait.phase {
605 finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
606 }
607 finite(
608 gait.lr_amplitude_m,
609 format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
610 )?;
611 }
612 if let Some(value) = clip.speed_mps {
613 finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
614 }
615 }
616 let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
617 path,
618 reason: reason.to_owned(),
619 };
620 let finite_aabb = |aabb: &Aabb, path: &str| {
621 for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
622 for (axis, value) in values.into_iter().enumerate() {
623 finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
624 }
625 }
626 for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
627 if min > max {
628 return Err(invalid(
629 format!("{path}.min[{axis}]"),
630 "AABB minimum cannot exceed maximum",
631 ));
632 }
633 }
634 Ok(())
635 };
636
637 let mut mesh_indices = BTreeSet::new();
638 for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
639 if !mesh_indices.insert(mesh.mesh_index) {
640 return Err(invalid(
641 format!("mesh_definitions[{index}].mesh_index"),
642 "mesh_index must be unique",
643 ));
644 }
645 if let Some(aabb) = &mesh.geometry_aabb {
646 finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
647 }
648 if let Some(centroid) = mesh.geometry_centroid {
649 for (axis, value) in centroid.into_iter().enumerate() {
650 finite(
651 f64::from(value),
652 format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
653 )?;
654 }
655 }
656 if let Some(value) = mesh.weight_sum_min {
657 finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
658 }
659 if let Some(value) = mesh.weight_sum_max {
660 finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
661 }
662 let mut previous_set_index = None;
663 for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
664 let path = format!(
665 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
666 );
667 if set.set_index == 0 {
668 return Err(invalid(path, "set_index must be at least 1"));
669 }
670 if !set.joints_present && !set.weights_present {
671 return Err(invalid(
672 format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
673 "an additional influence set must declare joints, weights, or both",
674 ));
675 }
676 if set.joints_without_weights_present && !set.joints_present {
677 return Err(invalid(
678 format!(
679 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
680 ),
681 "joints_without_weights_present requires joints_present",
682 ));
683 }
684 if set.weights_without_joints_present && !set.weights_present {
685 return Err(invalid(
686 format!(
687 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
688 ),
689 "weights_without_joints_present requires weights_present",
690 ));
691 }
692 if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
693 return Err(invalid(
694 format!(
695 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
696 ),
697 "joints_without_weights_present is required when weights_present is false",
698 ));
699 }
700 if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
701 return Err(invalid(
702 format!(
703 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
704 ),
705 "weights_without_joints_present is required when joints_present is false",
706 ));
707 }
708 if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
709 return Err(invalid(
710 path,
711 "set_index values must be strictly increasing and unique",
712 ));
713 }
714 previous_set_index = Some(set.set_index);
715 }
716 }
717
718 let mut node_indices = BTreeSet::new();
719 for (index, instance) in assets.node_instances.iter().enumerate() {
720 if !node_indices.insert(instance.node_index) {
721 return Err(invalid(
722 format!("node_instances[{index}].node_index"),
723 "node_index must be unique",
724 ));
725 }
726 if !mesh_indices.contains(&instance.mesh_index) {
727 return Err(invalid(
728 format!("node_instances[{index}].mesh_index"),
729 "mesh_index must reference a mesh definition",
730 ));
731 }
732 match (
733 instance.static_node_world_aabb.as_ref(),
734 instance.static_node_world_aabb_unavailable_reason,
735 ) {
736 (Some(aabb), None) => finite_aabb(
737 aabb,
738 &format!("node_instances[{index}].static_node_world_aabb"),
739 )?,
740 (None, Some(_)) => {}
741 (Some(_), Some(_)) => {
742 return Err(invalid(
743 format!("node_instances[{index}]"),
744 "an available static node AABB cannot have an unavailable reason",
745 ));
746 }
747 (None, None) => {
748 return Err(invalid(
749 format!("node_instances[{index}]"),
750 "a missing static node AABB requires an unavailable reason",
751 ));
752 }
753 }
754 }
755
756 let mut scene_indices = BTreeSet::new();
757 for (index, scene) in assets.scenes.iter().enumerate() {
758 if !scene_indices.insert(scene.scene_index) {
759 return Err(invalid(
760 format!("scenes[{index}].scene_index"),
761 "scene_index must be unique",
762 ));
763 }
764 if scene.excluded_instance_count > scene.instance_count {
765 return Err(invalid(
766 format!("scenes[{index}].excluded_instance_count"),
767 "excluded_instance_count cannot exceed instance_count",
768 ));
769 }
770 let available = scene.instance_count - scene.excluded_instance_count;
771 match (&scene.static_scene_world_aabb, available) {
772 (Some(aabb), 1..) => {
773 finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
774 }
775 (None, 0) => {}
776 (Some(_), 0) => {
777 return Err(invalid(
778 format!("scenes[{index}].static_scene_world_aabb"),
779 "a scene with no available instances cannot have an AABB",
780 ));
781 }
782 (None, _) => {
783 return Err(invalid(
784 format!("scenes[{index}].static_scene_world_aabb"),
785 "a scene with available instances requires an AABB",
786 ));
787 }
788 }
789 }
790 if let Some(default_scene_index) = assets.default_scene_index
791 && !scene_indices.contains(&default_scene_index)
792 {
793 return Err(invalid(
794 "default_scene_index".into(),
795 "default_scene_index must reference a declared scene",
796 ));
797 }
798 validate_skeleton_measurements(assets, &invalid)?;
799 validate_material_resources(assets, &invalid)?;
800 Ok(())
801}
802
803fn validate_linear_transform_fields(
804 linear: &LinearTransformMeasurements,
805 path: &str,
806 invalid: &impl Fn(String, &str) -> MeasurementContractError,
807) -> Result<(), MeasurementContractError> {
808 let numeric_fields_present = linear.axis_lengths.is_some()
809 && linear.determinant.is_some()
810 && linear.orientation.is_some();
811 if linear.classification == LinearTransformClassification::NonFinite {
812 if linear.axis_lengths.is_some()
813 || linear.determinant.is_some()
814 || linear.orientation.is_some()
815 || linear.uniform_scale.is_some()
816 {
817 return Err(invalid(
818 path.into(),
819 "a non_finite classification cannot carry numeric linear-transform facts",
820 ));
821 }
822 return Ok(());
823 }
824 if !numeric_fields_present {
825 return Err(invalid(
826 path.into(),
827 "a finite classification requires axis_lengths, determinant, and orientation",
828 ));
829 }
830 for (axis, value) in linear
831 .axis_lengths
832 .expect("presence checked")
833 .into_iter()
834 .enumerate()
835 {
836 if !value.is_finite() {
837 return Err(MeasurementContractError::NonFiniteValue {
838 path: format!("{path}.axis_lengths[{axis}]"),
839 });
840 }
841 if value < 0.0 {
842 return Err(invalid(
843 format!("{path}.axis_lengths[{axis}]"),
844 "axis lengths must be non-negative",
845 ));
846 }
847 }
848 if !linear.determinant.expect("presence checked").is_finite() {
849 return Err(MeasurementContractError::NonFiniteValue {
850 path: format!("{path}.determinant"),
851 });
852 }
853 if let Some(scale) = linear.uniform_scale {
854 if !scale.is_finite() {
855 return Err(MeasurementContractError::NonFiniteValue {
856 path: format!("{path}.uniform_scale"),
857 });
858 }
859 if scale < 0.0 {
860 return Err(invalid(
861 format!("{path}.uniform_scale"),
862 "uniform scale must be non-negative",
863 ));
864 }
865 }
866 Ok(())
867}
868
869fn validate_skeleton_measurements(
870 assets: &AssetMeasurements,
871 invalid: &impl Fn(String, &str) -> MeasurementContractError,
872) -> Result<(), MeasurementContractError> {
873 if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
874 if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
875 return Err(invalid(
876 "skeleton_source_coverage".into(),
877 "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
878 ));
879 }
880 return Ok(());
881 }
882
883 let finite_matrix = |matrix: &[f32; 16], path: &str| {
884 for (component, value) in matrix.iter().enumerate() {
885 if !value.is_finite() {
886 return Err(MeasurementContractError::NonFiniteValue {
887 path: format!("{path}[{component}]"),
888 });
889 }
890 }
891 Ok(())
892 };
893 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
894 if node.node_index != offset {
895 return Err(invalid(
896 format!("skeleton_nodes[{offset}].node_index"),
897 "node_index must be contiguous and match source order",
898 ));
899 }
900 match &node.local_rest {
901 SkeletonNodeLocalRestMeasurements::Trs {
902 translation_parent_space_m,
903 rotation_xyzw,
904 scale,
905 } => {
906 for (field, values) in [
907 (
908 "translation_parent_space_m",
909 translation_parent_space_m.as_slice(),
910 ),
911 ("rotation_xyzw", rotation_xyzw.as_slice()),
912 ("scale", scale.as_slice()),
913 ] {
914 for (component, value) in values.iter().enumerate() {
915 if !value.is_finite() {
916 return Err(MeasurementContractError::NonFiniteValue {
917 path: format!(
918 "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
919 ),
920 });
921 }
922 }
923 }
924 }
925 SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
926 matrix,
927 &format!("skeleton_nodes[{offset}].local_rest.matrix"),
928 )?,
929 SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
930 }
931 let node_path = format!("skeleton_nodes[{offset}]");
932 validate_linear_transform_fields(
933 &node.rest_world_linear,
934 &format!("{node_path}.rest_world_linear"),
935 invalid,
936 )?;
937 match (
938 node.rest_world_matrix.as_ref(),
939 node.rest_world_translation_m.as_ref(),
940 node.rest_world_matrix_unavailable_reason,
941 ) {
942 (Some(matrix), Some(translation), None) => {
943 finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
944 for (component, value) in translation.iter().enumerate() {
945 if !value.is_finite() {
946 return Err(MeasurementContractError::NonFiniteValue {
947 path: format!("{node_path}.rest_world_translation_m[{component}]"),
948 });
949 }
950 }
951 let expected_translation = [matrix[12], matrix[13], matrix[14]];
952 if *translation != expected_translation {
953 return Err(invalid(
954 format!("{node_path}.rest_world_translation_m"),
955 "rest_world_translation_m must equal the rest-world matrix translation column",
956 ));
957 }
958 let expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
959 if node.rest_world_linear != expected_linear {
960 return Err(invalid(
961 format!("{node_path}.rest_world_linear"),
962 "rest_world_linear must be derived from rest_world_matrix",
963 ));
964 }
965 }
966 (None, None, Some(_)) => {
967 if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
968 {
969 return Err(invalid(
970 format!("{node_path}.rest_world_linear"),
971 "an unavailable rest-world matrix requires a non_finite linear classification",
972 ));
973 }
974 }
975 (Some(_), Some(_), Some(_)) => {
976 return Err(invalid(
977 node_path,
978 "an available rest_world_matrix cannot have an unavailable reason",
979 ));
980 }
981 _ => {
982 return Err(invalid(
983 node_path,
984 "rest-world matrix, translation, and unavailable reason fields are inconsistent",
985 ));
986 }
987 }
988 }
989 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
990 if let Some(parent) = node.parent_node_index
991 && parent >= assets.skeleton_nodes.len()
992 {
993 return Err(invalid(
994 format!("skeleton_nodes[{offset}].parent_node_index"),
995 "parent_node_index must reference a skeleton node",
996 ));
997 }
998 let mut previous_scene = None;
999 for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
1000 if !assets
1001 .scenes
1002 .iter()
1003 .any(|scene| scene.scene_index == *scene_index)
1004 {
1005 return Err(invalid(
1006 format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1007 "scene_root_indices values must reference declared scenes",
1008 ));
1009 }
1010 if previous_scene.is_some_and(|previous| previous >= *scene_index) {
1011 return Err(invalid(
1012 format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1013 "scene_root_indices values must be strictly increasing and unique",
1014 ));
1015 }
1016 previous_scene = Some(*scene_index);
1017 }
1018 }
1019 let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
1020 for start in 0..assets.skeleton_nodes.len() {
1021 if visits.get(start) != Some(&ParentVisit::Unvisited) {
1022 continue;
1023 }
1024 let mut path = Vec::new();
1025 let mut current = start;
1026 loop {
1027 match visits.get(current).copied().ok_or_else(|| {
1028 invalid(
1029 format!("skeleton_nodes[{current}].parent_node_index"),
1030 "parent_node_index must reference a skeleton node",
1031 )
1032 })? {
1033 ParentVisit::Done => break,
1034 ParentVisit::Visiting => {
1035 return Err(invalid(
1036 format!("skeleton_nodes[{current}].parent_node_index"),
1037 "source node parent graph must be acyclic",
1038 ));
1039 }
1040 ParentVisit::Unvisited => {
1041 *visits.get_mut(current).ok_or_else(|| {
1042 invalid(
1043 format!("skeleton_nodes[{current}].parent_node_index"),
1044 "parent_node_index must reference a skeleton node",
1045 )
1046 })? = ParentVisit::Visiting;
1047 path.push(current);
1048 match assets
1049 .skeleton_nodes
1050 .get(current)
1051 .ok_or_else(|| {
1052 invalid(
1053 format!("skeleton_nodes[{current}].parent_node_index"),
1054 "parent_node_index must reference a skeleton node",
1055 )
1056 })?
1057 .parent_node_index
1058 {
1059 Some(parent) => current = parent,
1060 None => break,
1061 }
1062 }
1063 }
1064 }
1065 for node_index in path {
1066 *visits.get_mut(node_index).ok_or_else(|| {
1067 invalid(
1068 format!("skeleton_nodes[{node_index}].parent_node_index"),
1069 "parent_node_index must reference a skeleton node",
1070 )
1071 })? = ParentVisit::Done;
1072 }
1073 }
1074
1075 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1076 let local_rest_available = !matches!(
1077 node.local_rest,
1078 SkeletonNodeLocalRestMeasurements::Unavailable { .. }
1079 );
1080 let path = format!("skeleton_nodes[{offset}]");
1081 if !local_rest_available {
1082 if node.rest_world_matrix.is_some()
1083 || node.rest_world_matrix_unavailable_reason
1084 != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
1085 {
1086 return Err(invalid(
1087 path,
1088 "an unavailable local_rest requires a non_finite_local_rest rest-world result",
1089 ));
1090 }
1091 continue;
1092 }
1093
1094 let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
1095 let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
1096 invalid(
1097 format!("skeleton_nodes[{offset}].parent_node_index"),
1098 "parent_node_index must reference a skeleton node",
1099 )
1100 })?;
1101 if parent.rest_world_matrix.is_none() {
1102 Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
1103 } else {
1104 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
1105 }
1106 } else {
1107 None
1108 };
1109 match (
1110 node.rest_world_matrix.is_some(),
1111 expected_unavailable_reason,
1112 ) {
1113 (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
1114 }
1115 (false, Some(expected))
1116 if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
1117 _ => {
1118 return Err(invalid(
1119 path,
1120 "rest-world availability must agree with local rest and parent rest-world evidence",
1121 ));
1122 }
1123 }
1124 }
1125
1126 for (offset, skin) in assets.skins.iter().enumerate() {
1127 if skin.skin_index != offset {
1128 return Err(invalid(
1129 format!("skins[{offset}].skin_index"),
1130 "skin_index must be contiguous and match source order",
1131 ));
1132 }
1133 if let Some(root) = skin.skeleton_root_node_index
1134 && root >= assets.skeleton_nodes.len()
1135 {
1136 return Err(invalid(
1137 format!("skins[{offset}].skeleton_root_node_index"),
1138 "skeleton_root_node_index must reference a skeleton node",
1139 ));
1140 }
1141 for (joint_offset, joint) in skin.joints.iter().enumerate() {
1142 if joint.joint_index != joint_offset {
1143 return Err(invalid(
1144 format!("skins[{offset}].joints[{joint_offset}].joint_index"),
1145 "joint_index must be contiguous and match declared skin order",
1146 ));
1147 }
1148 if joint.node_index >= assets.skeleton_nodes.len() {
1149 return Err(invalid(
1150 format!("skins[{offset}].joints[{joint_offset}].node_index"),
1151 "joint node_index must reference a skeleton node",
1152 ));
1153 }
1154 }
1155 match skin.inverse_bind_accessor.status {
1156 SourceInverseBindAccessorStatus::Absent => {
1157 if skin.inverse_bind_accessor.declared_count.is_some()
1158 || !skin.inverse_bind_accessor.matrices.is_empty()
1159 {
1160 return Err(invalid(
1161 format!("skins[{offset}].inverse_bind_accessor"),
1162 "an absent inverse-bind declaration has no declared count or matrices",
1163 ));
1164 }
1165 }
1166 SourceInverseBindAccessorStatus::EmptyAccessor => {
1167 if skin.inverse_bind_accessor.declared_count != Some(0)
1168 || !skin.inverse_bind_accessor.matrices.is_empty()
1169 {
1170 return Err(invalid(
1171 format!("skins[{offset}].inverse_bind_accessor"),
1172 "an empty inverse-bind declaration has declared_count 0 and no matrices",
1173 ));
1174 }
1175 }
1176 SourceInverseBindAccessorStatus::Available => {
1177 if skin.inverse_bind_accessor.declared_count
1178 != Some(skin.inverse_bind_accessor.matrices.len())
1179 || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
1180 {
1181 return Err(invalid(
1182 format!("skins[{offset}].inverse_bind_accessor"),
1183 "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
1184 ));
1185 }
1186 }
1187 SourceInverseBindAccessorStatus::CountMismatch => {
1188 if skin.inverse_bind_accessor.declared_count
1189 != Some(skin.inverse_bind_accessor.matrices.len())
1190 || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
1191 {
1192 return Err(invalid(
1193 format!("skins[{offset}].inverse_bind_accessor"),
1194 "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
1195 ));
1196 }
1197 }
1198 SourceInverseBindAccessorStatus::Unreadable => {
1199 if skin.inverse_bind_accessor.declared_count.is_none()
1200 || !skin.inverse_bind_accessor.matrices.is_empty()
1201 {
1202 return Err(invalid(
1203 format!("skins[{offset}].inverse_bind_accessor"),
1204 "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
1205 ));
1206 }
1207 }
1208 }
1209 for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
1210 finite_matrix(
1211 matrix,
1212 &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
1213 )?;
1214 }
1215 for (joint_offset, joint) in skin.joints.iter().enumerate() {
1216 let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
1217 let joint_bind_path =
1218 format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
1219 validate_derived_matrix(
1220 &joint.joint_bind_to_mesh,
1221 &joint_bind_path,
1222 &finite_matrix,
1223 invalid,
1224 )?;
1225 validate_derived_reason_compatibility(
1226 &joint.joint_bind_to_mesh,
1227 skin.inverse_bind_accessor.status,
1228 skin.inverse_bind_accessor.matrices.len(),
1229 joint_offset,
1230 &joint_bind_path,
1231 DerivedMatrixDomain::JointBindToMesh,
1232 invalid,
1233 )?;
1234 validate_derived_source(
1235 &joint.joint_bind_to_mesh,
1236 expected_source,
1237 None,
1238 &joint_bind_path,
1239 DerivedMatrixDomain::JointBindToMesh,
1240 invalid,
1241 )?;
1242
1243 let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
1244 validate_derived_matrix(
1245 &joint.mesh_bind_world,
1246 &mesh_bind_path,
1247 &finite_matrix,
1248 invalid,
1249 )?;
1250 validate_derived_reason_compatibility(
1251 &joint.mesh_bind_world,
1252 skin.inverse_bind_accessor.status,
1253 skin.inverse_bind_accessor.matrices.len(),
1254 joint_offset,
1255 &mesh_bind_path,
1256 DerivedMatrixDomain::MeshBindWorld,
1257 invalid,
1258 )?;
1259 let joint_rest_world_available = assets
1260 .skeleton_nodes
1261 .get(joint.node_index)
1262 .ok_or_else(|| {
1263 invalid(
1264 format!("skins[{offset}].joints[{joint_offset}].node_index"),
1265 "joint node_index must reference a skeleton node",
1266 )
1267 })?
1268 .rest_world_matrix
1269 .is_some();
1270 let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1271 .rest_world_matrix
1272 .as_ref();
1273 validate_mesh_bind_world_reason_compatibility(
1274 &joint.mesh_bind_world,
1275 joint_rest_world_available,
1276 &mesh_bind_path,
1277 invalid,
1278 )?;
1279 validate_derived_source(
1280 &joint.mesh_bind_world,
1281 expected_source,
1282 joint_rest_world,
1283 &mesh_bind_path,
1284 DerivedMatrixDomain::MeshBindWorld,
1285 invalid,
1286 )?;
1287 }
1288 if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1289 && !scale.is_finite()
1290 {
1291 return Err(MeasurementContractError::NonFiniteValue {
1292 path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1293 });
1294 }
1295 let expected_summary = summarize_skin_bind_linear(&skin.joints);
1296 if skin.joint_bind_linear_summary != expected_summary {
1297 return Err(invalid(
1298 format!("skins[{offset}].joint_bind_linear_summary"),
1299 "joint-bind linear summary must match the skin joint observations",
1300 ));
1301 }
1302 let mut previous_attachment_node = None;
1303 for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1304 if attachment.node_index >= assets.skeleton_nodes.len() {
1305 return Err(invalid(
1306 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1307 "attachment node_index must reference a skeleton node",
1308 ));
1309 }
1310 if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1311 return Err(invalid(
1312 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1313 "attachment node_index values must be strictly increasing and unique",
1314 ));
1315 }
1316 previous_attachment_node = Some(attachment.node_index);
1317 }
1318 }
1319 Ok(())
1320}
1321
1322fn validate_derived_reason_compatibility(
1323 matrix: &SkinDerivedMatrixMeasurements,
1324 status: SourceInverseBindAccessorStatus,
1325 readable_matrix_count: usize,
1326 joint_index: usize,
1327 path: &str,
1328 domain: DerivedMatrixDomain,
1329 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1330) -> Result<(), MeasurementContractError> {
1331 let requires_accessor_reason = match status {
1332 SourceInverseBindAccessorStatus::Absent => {
1333 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1334 }
1335 SourceInverseBindAccessorStatus::EmptyAccessor => {
1336 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1337 }
1338 SourceInverseBindAccessorStatus::Unreadable => {
1339 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1340 }
1341 SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1342 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1343 }
1344 SourceInverseBindAccessorStatus::Available
1345 | SourceInverseBindAccessorStatus::CountMismatch => None,
1346 };
1347 if let Some(expected) = requires_accessor_reason {
1348 if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1349 return Err(invalid(
1350 path.into(),
1351 "derived matrices without a usable inverse bind must carry the matching accessor reason",
1352 ));
1353 }
1354 } else {
1355 match (domain, matrix.unavailable_reason) {
1356 (
1357 _,
1358 Some(
1359 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1360 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1361 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1362 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1363 ),
1364 ) => {
1365 return Err(invalid(
1366 format!("{path}.unavailable_reason"),
1367 "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1368 ));
1369 }
1370 (
1371 DerivedMatrixDomain::JointBindToMesh,
1372 Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1373 ) => {
1374 return Err(invalid(
1375 format!("{path}.unavailable_reason"),
1376 "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1377 ));
1378 }
1379 (
1380 DerivedMatrixDomain::MeshBindWorld,
1381 Some(
1382 SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1383 | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1384 | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1385 ),
1386 ) => {
1387 return Err(invalid(
1388 format!("{path}.unavailable_reason"),
1389 "mesh_bind_world does not require an invertible inverse-bind matrix",
1390 ));
1391 }
1392 _ => {}
1393 }
1394 }
1395 Ok(())
1396}
1397
1398fn validate_mesh_bind_world_reason_compatibility(
1399 matrix: &SkinDerivedMatrixMeasurements,
1400 joint_rest_world_available: bool,
1401 path: &str,
1402 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1403) -> Result<(), MeasurementContractError> {
1404 match matrix.unavailable_reason {
1405 Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1406 if joint_rest_world_available =>
1407 {
1408 Err(invalid(
1409 format!("{path}.unavailable_reason"),
1410 "an available joint rest-world matrix cannot be reported as unavailable",
1411 ))
1412 }
1413 Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1414 if !joint_rest_world_available =>
1415 {
1416 Err(invalid(
1417 format!("{path}.unavailable_reason"),
1418 "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1419 ))
1420 }
1421 _ => Ok(()),
1422 }
1423}
1424
1425#[derive(Clone, Copy, PartialEq, Eq)]
1426enum ParentVisit {
1427 Unvisited,
1428 Visiting,
1429 Done,
1430}
1431
1432#[derive(Clone, Copy)]
1433enum DerivedMatrixDomain {
1434 JointBindToMesh,
1435 MeshBindWorld,
1436}
1437
1438fn validate_derived_source(
1439 measurements: &SkinDerivedMatrixMeasurements,
1440 expected_source: Option<&[f32; 16]>,
1441 joint_rest_world: Option<&[f32; 16]>,
1442 path: &str,
1443 domain: DerivedMatrixDomain,
1444 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1445) -> Result<(), MeasurementContractError> {
1446 if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1447 return Err(invalid(
1448 format!("{path}.source_inverse_bind_matrix"),
1449 "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1450 ));
1451 }
1452 let Some(source) = expected_source else {
1453 if measurements.inversion_quality.is_some() {
1454 return Err(invalid(
1455 format!("{path}.inversion_quality"),
1456 "inversion quality requires a readable source inverse-bind matrix",
1457 ));
1458 }
1459 return Ok(());
1460 };
1461 let raw = Mat4::from_cols_array(source);
1462 match domain {
1463 DerivedMatrixDomain::JointBindToMesh => {
1464 let assessment = assess_inverse_bind(raw);
1465 if measurements.inversion_quality != assessment.quality {
1466 return Err(invalid(
1467 format!("{path}.inversion_quality"),
1468 "inversion quality must be derived from the source linear 3x3",
1469 ));
1470 }
1471 match assessment.inverse {
1472 Ok(inverse) => {
1473 if measurements.matrix != Some(inverse.to_cols_array())
1474 || measurements.unavailable_reason.is_some()
1475 {
1476 return Err(invalid(
1477 path.into(),
1478 "a trustworthy source inverse-bind matrix requires its exact inverse",
1479 ));
1480 }
1481 }
1482 Err(reason) => {
1483 if measurements.matrix.is_some()
1484 || measurements.unavailable_reason != Some(reason)
1485 {
1486 return Err(invalid(
1487 path.into(),
1488 "an untrustworthy source inverse-bind matrix requires its derived reason",
1489 ));
1490 }
1491 }
1492 }
1493 }
1494 DerivedMatrixDomain::MeshBindWorld => {
1495 if measurements.inversion_quality.is_some() {
1496 return Err(invalid(
1497 format!("{path}.inversion_quality"),
1498 "mesh_bind_world does not invert its source matrix",
1499 ));
1500 }
1501 if let Some(world) = joint_rest_world {
1502 let expected = Mat4::from_cols_array(world) * raw;
1503 if expected.to_cols_array().into_iter().all(f32::is_finite) {
1504 if measurements.matrix != Some(expected.to_cols_array())
1505 || measurements.unavailable_reason.is_some()
1506 {
1507 return Err(invalid(
1508 path.into(),
1509 "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1510 ));
1511 }
1512 } else if measurements.unavailable_reason
1513 != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1514 {
1515 return Err(invalid(
1516 format!("{path}.unavailable_reason"),
1517 "a non-finite mesh-bind product requires its typed unavailable reason",
1518 ));
1519 }
1520 }
1521 }
1522 }
1523 Ok(())
1524}
1525
1526fn validate_derived_matrix(
1527 matrix: &SkinDerivedMatrixMeasurements,
1528 path: &str,
1529 finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1530 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1531) -> Result<(), MeasurementContractError> {
1532 if let Some(source) = &matrix.source_inverse_bind_matrix {
1533 finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1534 }
1535 if let Some(quality) = matrix.inversion_quality {
1536 let value = quality.reciprocal_condition_number_inf;
1537 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1538 return Err(invalid(
1539 format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1540 "reciprocal condition number must be finite and between zero and one",
1541 ));
1542 }
1543 }
1544 match (
1545 &matrix.matrix,
1546 matrix.linear.as_ref(),
1547 matrix.unavailable_reason,
1548 ) {
1549 (Some(matrix), Some(linear), None) => {
1550 finite_matrix(matrix, &format!("{path}.matrix"))?;
1551 validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1552 if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1553 return Err(invalid(
1554 format!("{path}.linear"),
1555 "linear facts must be derived from the available matrix",
1556 ));
1557 }
1558 }
1559 (None, None, Some(_)) => {}
1560 (Some(_), Some(_), Some(_)) => {
1561 return Err(invalid(
1562 path.into(),
1563 "an available derived matrix cannot have an unavailable reason",
1564 ));
1565 }
1566 _ => {
1567 return Err(invalid(
1568 path.into(),
1569 "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1570 ));
1571 }
1572 }
1573 Ok(())
1574}
1575
1576fn validate_material_resources(
1577 assets: &AssetMeasurements,
1578 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1579) -> Result<(), MeasurementContractError> {
1580 let absent = assets.material_definitions.is_empty()
1581 && assets.textures.is_empty()
1582 && assets.images.is_empty();
1583 if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1584 return Err(invalid(
1585 "material_resource_coverage".into(),
1586 "unavailable resource coverage requires empty material, texture, and image arrays",
1587 ));
1588 }
1589
1590 for (offset, material) in assets.material_definitions.iter().enumerate() {
1591 if material.material_index != offset {
1592 return Err(invalid(
1593 format!("material_definitions[{offset}].material_index"),
1594 "material_index must be contiguous and match source order",
1595 ));
1596 }
1597 let mut previous_slot = None;
1598 for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1599 if binding.texture_index >= assets.textures.len() {
1600 return Err(invalid(
1601 format!(
1602 "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1603 ),
1604 "texture_index must reference a source texture",
1605 ));
1606 }
1607 if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1608 return Err(invalid(
1609 format!(
1610 "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1611 ),
1612 "texture bindings must be strictly ordered by slot and unique",
1613 ));
1614 }
1615 previous_slot = Some(binding.slot);
1616 }
1617 }
1618 for (offset, texture) in assets.textures.iter().enumerate() {
1619 if texture.texture_index != offset {
1620 return Err(invalid(
1621 format!("textures[{offset}].texture_index"),
1622 "texture_index must be contiguous and match source order",
1623 ));
1624 }
1625 if texture.image_index >= assets.images.len() {
1626 return Err(invalid(
1627 format!("textures[{offset}].image_index"),
1628 "image_index must reference a source image",
1629 ));
1630 }
1631 }
1632 for (offset, image) in assets.images.iter().enumerate() {
1633 validate_image_measurement(image, offset, invalid)?;
1634 }
1635 Ok(())
1636}
1637
1638fn validate_image_measurement(
1639 image: &ImageMeasurements,
1640 offset: usize,
1641 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1642) -> Result<(), MeasurementContractError> {
1643 if image.image_index != offset {
1644 return Err(invalid(
1645 format!("images[{offset}].image_index"),
1646 "image_index must be contiguous and match source order",
1647 ));
1648 }
1649 let available = [
1650 image.width.is_some(),
1651 image.height.is_some(),
1652 image.channel_count.is_some(),
1653 image.decoded_color_type.is_some(),
1654 ];
1655 match (
1656 available.into_iter().all(|value| value),
1657 image.unavailable_reason,
1658 ) {
1659 (true, None) => {
1660 let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1661 image.width,
1662 image.height,
1663 image.channel_count,
1664 image.decoded_color_type,
1665 ) else {
1666 return Err(invalid(
1667 format!("images[{offset}]"),
1668 "available image metadata must include width, height, channel_count, and decoded_color_type",
1669 ));
1670 };
1671 if width == 0 || height == 0 {
1672 return Err(invalid(
1673 format!("images[{offset}]"),
1674 "available image dimensions must be greater than zero",
1675 ));
1676 }
1677 if channel_count != color_type_channel_count(decoded_color_type) {
1678 return Err(invalid(
1679 format!("images[{offset}].channel_count"),
1680 "channel_count must match decoded_color_type",
1681 ));
1682 }
1683 if image.detected_container.is_none() {
1684 return Err(invalid(
1685 format!("images[{offset}].detected_container"),
1686 "available image metadata requires a detected_container",
1687 ));
1688 }
1689 }
1690 (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1691 (true, Some(_)) => {
1692 return Err(invalid(
1693 format!("images[{offset}]"),
1694 "available image metadata cannot have an unavailable_reason",
1695 ));
1696 }
1697 (false, None) if available.into_iter().all(|value| !value) => {
1698 return Err(invalid(
1699 format!("images[{offset}]"),
1700 "missing image metadata requires an unavailable_reason",
1701 ));
1702 }
1703 (false, _) => {
1704 return Err(invalid(
1705 format!("images[{offset}]"),
1706 "available image metadata must include width, height, channel_count, and decoded_color_type",
1707 ));
1708 }
1709 }
1710 match image.unavailable_reason {
1711 Some(crate::model::ImageUnavailableReason::DecodeFailed)
1712 if image.detected_container.is_none() =>
1713 {
1714 return Err(invalid(
1715 format!("images[{offset}].detected_container"),
1716 "decode_failed requires a detected_container",
1717 ));
1718 }
1719 Some(
1720 crate::model::ImageUnavailableReason::SourceUnavailable
1721 | crate::model::ImageUnavailableReason::InvalidDataUri
1722 | crate::model::ImageUnavailableReason::UnsupportedContainer,
1723 ) if image.detected_container.is_some() => {
1724 return Err(invalid(
1725 format!("images[{offset}].detected_container"),
1726 "this unavailable_reason cannot have a detected_container",
1727 ));
1728 }
1729 _ => {}
1730 }
1731 Ok(())
1732}
1733
1734fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
1735 match color_type {
1736 DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
1737 DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
1738 DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
1739 DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
1740 }
1741}
1742
1743#[derive(Debug)]
1751pub struct MeasurementReportInput {
1752 schema_version: Option<u32>,
1753 schema: Option<String>,
1754 _tool: Option<Box<RawValue>>,
1755 command: Option<String>,
1756 summary: Option<MeasurementReportSummaryInput>,
1757 files: Option<Vec<Box<RawValue>>>,
1758 _inputs: Option<Box<RawValue>>,
1759 _deltas: Option<Box<RawValue>>,
1760 extra: BTreeMap<String, Box<RawValue>>,
1761}
1762
1763impl<'de> Deserialize<'de> for MeasurementReportInput {
1764 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1765 where
1766 D: Deserializer<'de>,
1767 {
1768 struct MeasurementReportInputVisitor;
1769
1770 impl<'de> Visitor<'de> for MeasurementReportInputVisitor {
1771 type Value = MeasurementReportInput;
1772
1773 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1774 formatter.write_str("an output report object")
1775 }
1776
1777 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1778 where
1779 A: MapAccess<'de>,
1780 {
1781 let mut schema_version = None;
1782 let mut schema = None;
1783 let mut tool = None;
1784 let mut command = None;
1785 let mut summary = None;
1786 let mut files = None;
1787 let mut inputs = None;
1788 let mut deltas = None;
1789 let mut extra = BTreeMap::new();
1790 while let Some(field) = map.next_key::<String>()? {
1791 match field.as_str() {
1792 "schema_version" => {
1793 if schema_version.is_some() {
1794 return Err(serde::de::Error::duplicate_field("schema_version"));
1795 }
1796 schema_version = Some(map.next_value()?);
1797 }
1798 "schema" => {
1799 if schema.is_some() {
1800 return Err(serde::de::Error::duplicate_field("schema"));
1801 }
1802 schema = Some(map.next_value()?);
1803 }
1804 "tool" => {
1805 if tool.is_some() {
1806 return Err(serde::de::Error::duplicate_field("tool"));
1807 }
1808 tool = Some(map.next_value()?);
1809 }
1810 "command" => {
1811 if command.is_some() {
1812 return Err(serde::de::Error::duplicate_field("command"));
1813 }
1814 command = Some(map.next_value()?);
1815 }
1816 "summary" => {
1817 if summary.is_some() {
1818 return Err(serde::de::Error::duplicate_field("summary"));
1819 }
1820 summary = Some(map.next_value()?);
1821 }
1822 "files" => {
1823 if files.is_some() {
1824 return Err(serde::de::Error::duplicate_field("files"));
1825 }
1826 files = Some(map.next_value()?);
1827 }
1828 "inputs" => {
1829 if inputs.is_some() {
1830 return Err(serde::de::Error::duplicate_field("inputs"));
1831 }
1832 inputs = Some(map.next_value()?);
1833 }
1834 "deltas" => {
1835 if deltas.is_some() {
1836 return Err(serde::de::Error::duplicate_field("deltas"));
1837 }
1838 deltas = Some(map.next_value()?);
1839 }
1840 _ => {
1841 extra.insert(field, map.next_value()?);
1842 }
1843 }
1844 }
1845 Ok(MeasurementReportInput {
1846 schema_version: schema_version.unwrap_or_default(),
1847 schema: schema.unwrap_or_default(),
1848 _tool: tool,
1849 command: command.unwrap_or_default(),
1850 summary: summary.unwrap_or_default(),
1851 files: files.unwrap_or_default(),
1852 _inputs: inputs.unwrap_or_default(),
1853 _deltas: deltas.unwrap_or_default(),
1854 extra,
1855 })
1856 }
1857 }
1858
1859 deserializer.deserialize_map(MeasurementReportInputVisitor)
1860 }
1861}
1862
1863#[derive(Debug, Deserialize)]
1864#[serde(deny_unknown_fields)]
1865struct MeasurementFileWireInput {
1866 path: Option<String>,
1867 input: Option<InputIdentityInput>,
1868 #[serde(rename = "rig")]
1869 _rig: Box<RawValue>,
1870 measurements: Option<Box<RawValue>>,
1871 #[serde(default, deserialize_with = "deserialize_required_nullable")]
1872 prediction_provenance: RequiredNullable<Box<RawValue>>,
1873 checks: Option<Vec<Box<RawValue>>>,
1874}
1875
1876#[derive(Debug)]
1877struct MeasurementFileInput {
1878 path: Option<String>,
1879 input: Option<InputIdentityInput>,
1880 measurements: Option<Box<RawValue>>,
1881 prediction_provenance: RequiredNullable<PredictionProvenanceV1>,
1882 checks: Option<Vec<PredictionCheckInput>>,
1883}
1884
1885#[derive(Debug, Default)]
1886enum RequiredNullable<T> {
1887 #[default]
1888 Missing,
1889 Present(Option<T>),
1890}
1891
1892fn deserialize_required_nullable<'de, D, T>(
1893 deserializer: D,
1894) -> Result<RequiredNullable<T>, D::Error>
1895where
1896 D: Deserializer<'de>,
1897 T: Deserialize<'de>,
1898{
1899 Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
1900}
1901
1902#[derive(Debug, Deserialize)]
1903#[serde(deny_unknown_fields)]
1904struct MeasurementReportSummaryInput {
1905 #[serde(rename = "files")]
1906 _files: Option<Box<RawValue>>,
1907 #[serde(rename = "findings")]
1908 _findings: Option<Box<RawValue>>,
1909 #[serde(rename = "checks")]
1910 _checks: Option<Box<RawValue>>,
1911 #[serde(rename = "deltas")]
1912 _deltas: Option<Box<RawValue>>,
1913 prediction_facets: Option<PredictionFacetSummaryInput>,
1914}
1915
1916#[derive(Debug, Deserialize)]
1917#[serde(deny_unknown_fields)]
1918struct PredictionFacetSummaryInput {
1919 available: usize,
1920 required_prediction_unavailable: usize,
1921}
1922
1923#[derive(Debug, Deserialize)]
1924#[serde(deny_unknown_fields)]
1925struct PredictionCheckWireInput {
1926 check_id: String,
1927 selection: SelectionState,
1928 configuration: ConfigurationState,
1929 applicability: Applicability,
1930 evaluation: EvaluationState,
1931 findings: Vec<PredictionFindingInput>,
1932 #[serde(default)]
1933 evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
1934 #[serde(default)]
1935 gaps: Vec<PredictionGapInput>,
1936 prediction: Option<Box<RawValue>>,
1937}
1938
1939#[derive(Debug)]
1940struct PredictionCheckInput {
1941 check_id: String,
1942 selection: SelectionState,
1943 configuration: ConfigurationState,
1944 applicability: Applicability,
1945 evaluation: EvaluationState,
1946 findings: Vec<PredictionFindingInput>,
1947 evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
1948 gaps: Vec<PredictionGapInput>,
1949 prediction: Option<crate::prediction::EnginePredictionV1>,
1950}
1951
1952#[derive(Debug, Deserialize)]
1953#[serde(deny_unknown_fields)]
1954struct PredictionFindingInput {
1955 check_id: String,
1956 #[serde(rename = "severity")]
1957 _severity: PredictionSeverityInput,
1958 #[serde(rename = "clip")]
1959 _clip: Option<String>,
1960 #[serde(rename = "bone")]
1961 _bone: Option<String>,
1962 #[serde(rename = "node")]
1963 _node: Option<String>,
1964 prediction_scope: Option<crate::evaluation::EvaluationScope>,
1965 #[serde(rename = "time_s")]
1966 _time_s: Option<f32>,
1967 #[serde(rename = "measured")]
1968 _measured: Option<Box<RawValue>>,
1969 #[serde(rename = "expected")]
1970 _expected: Option<Box<RawValue>>,
1971 #[serde(rename = "members")]
1972 _members: Option<Box<RawValue>>,
1973 #[serde(rename = "message")]
1974 _message: String,
1975}
1976
1977#[derive(Debug, Deserialize)]
1978#[serde(deny_unknown_fields)]
1979struct PredictionGapInput {
1980 code: String,
1981 #[serde(rename = "message")]
1982 _message: String,
1983 scope: Option<crate::evaluation::EvaluationScope>,
1984}
1985
1986#[derive(Debug, Deserialize)]
1987#[serde(rename_all = "snake_case")]
1988enum PredictionSeverityInput {
1989 Error,
1990 Warning,
1991 Note,
1992}
1993
1994#[derive(Debug, Deserialize)]
1995#[serde(deny_unknown_fields)]
1996struct InputIdentityInput {
1997 sha256: Option<String>,
1998 bytes: Option<u64>,
1999}
2000
2001struct MeasurementF32NarrowingDeserializer<D>(D);
2011
2012macro_rules! delegate_measurement_deserializer {
2013 ($method:ident $(, $argument:ident: $argument_type:ty)*) => {
2014 fn $method<V>(
2015 self,
2016 $($argument: $argument_type,)*
2017 visitor: V,
2018 ) -> Result<V::Value, Self::Error>
2019 where
2020 V: Visitor<'de>,
2021 {
2022 self.0.$method(
2023 $($argument,)*
2024 MeasurementF32NarrowingVisitor(visitor),
2025 )
2026 }
2027 };
2028}
2029
2030impl<'de, D> Deserializer<'de> for MeasurementF32NarrowingDeserializer<D>
2031where
2032 D: Deserializer<'de>,
2033{
2034 type Error = D::Error;
2035
2036 delegate_measurement_deserializer!(deserialize_any);
2037 delegate_measurement_deserializer!(deserialize_bool);
2038 delegate_measurement_deserializer!(deserialize_i8);
2039 delegate_measurement_deserializer!(deserialize_i16);
2040 delegate_measurement_deserializer!(deserialize_i32);
2041 delegate_measurement_deserializer!(deserialize_i64);
2042 delegate_measurement_deserializer!(deserialize_i128);
2043 delegate_measurement_deserializer!(deserialize_u8);
2044 delegate_measurement_deserializer!(deserialize_u16);
2045 delegate_measurement_deserializer!(deserialize_u32);
2046 delegate_measurement_deserializer!(deserialize_u64);
2047 delegate_measurement_deserializer!(deserialize_u128);
2048
2049 fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
2050 where
2051 V: Visitor<'de>,
2052 {
2053 self.0
2054 .deserialize_f64(MeasurementF32NarrowingNumberVisitor(visitor))
2055 }
2056
2057 delegate_measurement_deserializer!(deserialize_f64);
2058 delegate_measurement_deserializer!(deserialize_char);
2059 delegate_measurement_deserializer!(deserialize_str);
2060 delegate_measurement_deserializer!(deserialize_string);
2061 delegate_measurement_deserializer!(deserialize_bytes);
2062 delegate_measurement_deserializer!(deserialize_byte_buf);
2063 delegate_measurement_deserializer!(deserialize_option);
2064 delegate_measurement_deserializer!(deserialize_unit);
2065 delegate_measurement_deserializer!(deserialize_unit_struct, name: &'static str);
2066 delegate_measurement_deserializer!(deserialize_newtype_struct, name: &'static str);
2067 delegate_measurement_deserializer!(deserialize_seq);
2068 delegate_measurement_deserializer!(deserialize_tuple, len: usize);
2069 delegate_measurement_deserializer!(
2070 deserialize_tuple_struct,
2071 name: &'static str,
2072 len: usize
2073 );
2074 delegate_measurement_deserializer!(deserialize_map);
2075 delegate_measurement_deserializer!(
2076 deserialize_struct,
2077 name: &'static str,
2078 fields: &'static [&'static str]
2079 );
2080 delegate_measurement_deserializer!(
2081 deserialize_enum,
2082 name: &'static str,
2083 variants: &'static [&'static str]
2084 );
2085 delegate_measurement_deserializer!(deserialize_identifier);
2086 delegate_measurement_deserializer!(deserialize_ignored_any);
2087
2088 fn is_human_readable(&self) -> bool {
2089 self.0.is_human_readable()
2090 }
2091}
2092
2093struct MeasurementF32NarrowingNumberVisitor<V>(V);
2094
2095impl<'de, V> Visitor<'de> for MeasurementF32NarrowingNumberVisitor<V>
2096where
2097 V: Visitor<'de>,
2098{
2099 type Value = V::Value;
2100
2101 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2102 self.0.expecting(formatter)
2103 }
2104
2105 fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
2106 where
2107 E: serde::de::Error,
2108 {
2109 self.0.visit_f32(value)
2110 }
2111
2112 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
2113 where
2114 E: serde::de::Error,
2115 {
2116 self.0.visit_f32(value as f32)
2117 }
2118
2119 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
2120 where
2121 E: serde::de::Error,
2122 {
2123 self.0.visit_f32(value as f32)
2124 }
2125
2126 fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
2127 where
2128 E: serde::de::Error,
2129 {
2130 self.0.visit_f32(value as f32)
2131 }
2132
2133 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
2134 where
2135 E: serde::de::Error,
2136 {
2137 self.0.visit_f32(value as f32)
2138 }
2139
2140 fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
2141 where
2142 E: serde::de::Error,
2143 {
2144 self.0.visit_f32(value as f32)
2145 }
2146}
2147
2148struct MeasurementF32NarrowingVisitor<V>(V);
2149
2150macro_rules! delegate_measurement_visitor {
2151 ($method:ident, $value_type:ty) => {
2152 fn $method<E>(self, value: $value_type) -> Result<Self::Value, E>
2153 where
2154 E: serde::de::Error,
2155 {
2156 self.0.$method(value)
2157 }
2158 };
2159}
2160
2161impl<'de, V> Visitor<'de> for MeasurementF32NarrowingVisitor<V>
2162where
2163 V: Visitor<'de>,
2164{
2165 type Value = V::Value;
2166
2167 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2168 self.0.expecting(formatter)
2169 }
2170
2171 delegate_measurement_visitor!(visit_bool, bool);
2172 delegate_measurement_visitor!(visit_i8, i8);
2173 delegate_measurement_visitor!(visit_i16, i16);
2174 delegate_measurement_visitor!(visit_i32, i32);
2175 delegate_measurement_visitor!(visit_i64, i64);
2176 delegate_measurement_visitor!(visit_i128, i128);
2177 delegate_measurement_visitor!(visit_u8, u8);
2178 delegate_measurement_visitor!(visit_u16, u16);
2179 delegate_measurement_visitor!(visit_u32, u32);
2180 delegate_measurement_visitor!(visit_u64, u64);
2181 delegate_measurement_visitor!(visit_u128, u128);
2182 delegate_measurement_visitor!(visit_f32, f32);
2183 delegate_measurement_visitor!(visit_f64, f64);
2184 delegate_measurement_visitor!(visit_char, char);
2185
2186 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
2187 where
2188 E: serde::de::Error,
2189 {
2190 self.0.visit_str(value)
2191 }
2192
2193 fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
2194 where
2195 E: serde::de::Error,
2196 {
2197 self.0.visit_borrowed_str(value)
2198 }
2199
2200 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
2201 where
2202 E: serde::de::Error,
2203 {
2204 self.0.visit_string(value)
2205 }
2206
2207 fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
2208 where
2209 E: serde::de::Error,
2210 {
2211 self.0.visit_bytes(value)
2212 }
2213
2214 fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
2215 where
2216 E: serde::de::Error,
2217 {
2218 self.0.visit_borrowed_bytes(value)
2219 }
2220
2221 fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
2222 where
2223 E: serde::de::Error,
2224 {
2225 self.0.visit_byte_buf(value)
2226 }
2227
2228 fn visit_none<E>(self) -> Result<Self::Value, E>
2229 where
2230 E: serde::de::Error,
2231 {
2232 self.0.visit_none()
2233 }
2234
2235 fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2236 where
2237 D: Deserializer<'de>,
2238 {
2239 self.0
2240 .visit_some(MeasurementF32NarrowingDeserializer(deserializer))
2241 }
2242
2243 fn visit_unit<E>(self) -> Result<Self::Value, E>
2244 where
2245 E: serde::de::Error,
2246 {
2247 self.0.visit_unit()
2248 }
2249
2250 fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2251 where
2252 D: Deserializer<'de>,
2253 {
2254 self.0
2255 .visit_newtype_struct(MeasurementF32NarrowingDeserializer(deserializer))
2256 }
2257
2258 fn visit_seq<A>(self, sequence: A) -> Result<Self::Value, A::Error>
2259 where
2260 A: SeqAccess<'de>,
2261 {
2262 self.0.visit_seq(MeasurementF32NarrowingSeqAccess(sequence))
2263 }
2264
2265 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
2266 where
2267 A: MapAccess<'de>,
2268 {
2269 self.0.visit_map(MeasurementF32NarrowingMapAccess(map))
2270 }
2271
2272 fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
2273 where
2274 A: EnumAccess<'de>,
2275 {
2276 self.0.visit_enum(MeasurementF32NarrowingEnumAccess(data))
2277 }
2278}
2279
2280struct MeasurementF32NarrowingSeed<S>(S);
2281
2282impl<'de, S> DeserializeSeed<'de> for MeasurementF32NarrowingSeed<S>
2283where
2284 S: DeserializeSeed<'de>,
2285{
2286 type Value = S::Value;
2287
2288 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2289 where
2290 D: Deserializer<'de>,
2291 {
2292 self.0
2293 .deserialize(MeasurementF32NarrowingDeserializer(deserializer))
2294 }
2295}
2296
2297struct MeasurementF32NarrowingSeqAccess<A>(A);
2298
2299impl<'de, A> SeqAccess<'de> for MeasurementF32NarrowingSeqAccess<A>
2300where
2301 A: SeqAccess<'de>,
2302{
2303 type Error = A::Error;
2304
2305 fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
2306 where
2307 T: DeserializeSeed<'de>,
2308 {
2309 self.0.next_element_seed(MeasurementF32NarrowingSeed(seed))
2310 }
2311
2312 fn size_hint(&self) -> Option<usize> {
2313 self.0.size_hint()
2314 }
2315}
2316
2317struct MeasurementF32NarrowingMapAccess<A>(A);
2318
2319impl<'de, A> MapAccess<'de> for MeasurementF32NarrowingMapAccess<A>
2320where
2321 A: MapAccess<'de>,
2322{
2323 type Error = A::Error;
2324
2325 fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
2326 where
2327 K: DeserializeSeed<'de>,
2328 {
2329 self.0.next_key_seed(MeasurementF32NarrowingSeed(seed))
2330 }
2331
2332 fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
2333 where
2334 V: DeserializeSeed<'de>,
2335 {
2336 self.0.next_value_seed(MeasurementF32NarrowingSeed(seed))
2337 }
2338
2339 fn size_hint(&self) -> Option<usize> {
2340 self.0.size_hint()
2341 }
2342}
2343
2344struct MeasurementF32NarrowingEnumAccess<A>(A);
2345
2346impl<'de, A> EnumAccess<'de> for MeasurementF32NarrowingEnumAccess<A>
2347where
2348 A: EnumAccess<'de>,
2349{
2350 type Error = A::Error;
2351 type Variant = MeasurementF32NarrowingVariantAccess<A::Variant>;
2352
2353 fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
2354 where
2355 V: DeserializeSeed<'de>,
2356 {
2357 let (value, variant) = self.0.variant_seed(MeasurementF32NarrowingSeed(seed))?;
2358 Ok((value, MeasurementF32NarrowingVariantAccess(variant)))
2359 }
2360}
2361
2362struct MeasurementF32NarrowingVariantAccess<A>(A);
2363
2364impl<'de, A> VariantAccess<'de> for MeasurementF32NarrowingVariantAccess<A>
2365where
2366 A: VariantAccess<'de>,
2367{
2368 type Error = A::Error;
2369
2370 fn unit_variant(self) -> Result<(), Self::Error> {
2371 self.0.unit_variant()
2372 }
2373
2374 fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
2375 where
2376 T: DeserializeSeed<'de>,
2377 {
2378 self.0
2379 .newtype_variant_seed(MeasurementF32NarrowingSeed(seed))
2380 }
2381
2382 fn tuple_variant<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>
2383 where
2384 V: Visitor<'de>,
2385 {
2386 self.0
2387 .tuple_variant(len, MeasurementF32NarrowingVisitor(visitor))
2388 }
2389
2390 fn struct_variant<V>(
2391 self,
2392 fields: &'static [&'static str],
2393 visitor: V,
2394 ) -> Result<V::Value, Self::Error>
2395 where
2396 V: Visitor<'de>,
2397 {
2398 self.0
2399 .struct_variant(fields, MeasurementF32NarrowingVisitor(visitor))
2400 }
2401}
2402
2403#[derive(Debug, Deserialize)]
2404#[serde(untagged)]
2405enum SkeletonNodeMeasurementInput {
2406 Current(Box<crate::measure::SkeletonNodeMeasurements>),
2407 Earlier {
2408 #[serde(rename = "node_index")]
2409 _node_index: usize,
2410 },
2411}
2412
2413#[derive(Debug, Deserialize)]
2414#[serde(untagged)]
2415enum SkinMeasurementInput {
2416 Current(Box<crate::measure::SkinMeasurements>),
2417 Earlier {
2418 #[serde(rename = "skin_index")]
2419 _skin_index: usize,
2420 },
2421}
2422
2423#[derive(Debug, Deserialize)]
2424struct MeasurementPayloadInput {
2425 schema_version: Option<u32>,
2426 schema: Option<String>,
2427 clips: Option<BTreeMap<String, ClipMeasurements>>,
2428 material_resource_coverage: Option<MaterialResourceCoverage>,
2429 material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
2430 textures: Option<Vec<TextureMeasurements>>,
2431 images: Option<Vec<ImageMeasurements>>,
2432 skeleton_source_coverage: Option<SourceSkeletonCoverage>,
2433 skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
2434 skins: Option<Vec<SkinMeasurementInput>>,
2435 mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
2436 node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
2437 scenes: Option<Vec<crate::measure::SceneMeasurements>>,
2438 default_scene_index: Option<usize>,
2439}
2440
2441fn decode_measurement_payload(
2442 raw: &RawValue,
2443) -> Result<MeasurementPayloadInput, serde_json::Error> {
2444 let mut deserializer = serde_json::Deserializer::from_str(raw.get());
2445 let payload = MeasurementPayloadInput::deserialize(MeasurementF32NarrowingDeserializer(
2446 &mut deserializer,
2447 ))?;
2448 deserializer.end()?;
2449 Ok(payload)
2450}
2451
2452#[derive(Debug, Clone)]
2458pub struct MeasurementReportFile {
2459 path: String,
2460 input: InputIdentity,
2461 measurements: MeasurementContract,
2462}
2463
2464impl MeasurementReportFile {
2465 pub fn path(&self) -> &str {
2467 &self.path
2468 }
2469
2470 pub fn input(&self) -> &InputIdentity {
2472 &self.input
2473 }
2474
2475 pub fn measurements(&self) -> &MeasurementContract {
2477 &self.measurements
2478 }
2479
2480 pub fn into_measurements(self) -> MeasurementContract {
2482 self.measurements
2483 }
2484}
2485
2486#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2488#[non_exhaustive]
2489pub enum MeasurementReportError {
2490 #[error("report envelope has no `schema_version`")]
2492 MissingOutputVersion,
2493 #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
2495 UnsupportedOutputVersion {
2496 found: u32,
2498 },
2499 #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
2501 WrongOutputIdentity,
2502 #[error("report envelope has no `command`")]
2504 MissingCommand,
2505 #[error("report command {command:?} does not carry measurement file records")]
2507 UnsupportedCommand {
2508 command: String,
2510 },
2511 #[error("report envelope has unknown field `{field}`")]
2513 UnknownOutputField {
2514 field: String,
2516 },
2517 #[error("report envelope has no `tool` object")]
2519 MissingTool,
2520 #[error("report envelope has no `files` array")]
2522 MissingFiles,
2523 #[error("report contains {found} files, exceeding the output-v10 limit of {limit}")]
2525 TooManyFiles {
2526 found: usize,
2528 limit: usize,
2530 },
2531 #[error("lint report summary has no `prediction_facets` object")]
2533 MissingPredictionFacetSummary,
2534 #[error("measure report summary must not carry `prediction_facets`")]
2536 UnexpectedPredictionFacetSummary,
2537 #[error("lint report prediction-facet summary does not match its check records")]
2539 PredictionFacetSummaryMismatch,
2540 #[error("files[{file_index}] {source}")]
2542 File {
2543 file_index: usize,
2545 #[source]
2547 source: MeasurementFileError,
2548 },
2549}
2550
2551#[derive(Debug, thiserror::Error)]
2553#[non_exhaustive]
2554pub enum MeasurementReportReadError {
2555 #[error("cannot read report: {source}")]
2557 Io {
2558 #[source]
2560 source: std::io::Error,
2561 },
2562 #[error("report exceeds the output-v10 limit of {limit} bytes")]
2564 ReportTooLarge {
2565 limit: u64,
2567 },
2568 #[error("invalid report JSON: {source}")]
2570 InvalidJson {
2571 #[source]
2573 source: serde_json::Error,
2574 },
2575}
2576
2577#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2579#[non_exhaustive]
2580pub enum MeasurementFileError {
2581 #[error("has invalid output-v10 file shape: {reason}")]
2584 InvalidFileShape {
2585 reason: String,
2587 },
2588 #[error("has no `path`")]
2590 MissingPath,
2591 #[error("has no `input`")]
2593 MissingInput,
2594 #[error("input has no `sha256`")]
2596 MissingSha256,
2597 #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
2599 InvalidSha256,
2600 #[error("input has no `bytes`")]
2602 MissingBytes,
2603 #[error("has no measurements")]
2605 MissingMeasurements,
2606 #[error("has no required `prediction_provenance` field")]
2608 MissingPredictionProvenance,
2609 #[error("measure file must not carry `prediction_provenance`")]
2611 UnexpectedPredictionProvenance,
2612 #[error("lint file has no `checks` array")]
2614 MissingChecks,
2615 #[error("measure file must not carry `checks`")]
2617 UnexpectedChecks,
2618 #[error("contains {found} checks, exceeding the output-v10 limit of {limit}")]
2620 TooManyChecks {
2621 found: usize,
2623 limit: usize,
2625 },
2626 #[error("prediction provenance primary input does not match file input")]
2628 PredictionPrimaryInputMismatch,
2629 #[error("has invalid prediction provenance: {source}")]
2631 InvalidPredictionProvenance {
2632 #[source]
2634 source: PredictionContractError,
2635 },
2636 #[error("has invalid prediction provenance shape: {reason}")]
2638 InvalidPredictionProvenanceShape {
2639 reason: String,
2641 },
2642 #[error("checks[{check_index}] has prediction without non-null file provenance")]
2644 PredictionWithoutProvenance {
2645 check_index: usize,
2647 },
2648 #[error("checks[{check_index}] has invalid prediction evidence: {source}")]
2650 InvalidPrediction {
2651 check_index: usize,
2653 #[source]
2655 source: PredictionContractError,
2656 },
2657 #[error("checks[{check_index}] has invalid prediction shape: {reason}")]
2659 InvalidPredictionShape {
2660 check_index: usize,
2662 reason: String,
2664 },
2665 #[error("checks[{check_index}] has invalid prediction lifecycle: {reason}")]
2667 InvalidPredictionLifecycle {
2668 check_index: usize,
2670 reason: &'static str,
2672 },
2673 #[error("contains {found} prediction facets, exceeding the V1 limit of {limit}")]
2675 TooManyPredictionFacets {
2676 found: usize,
2678 limit: usize,
2680 },
2681 #[error("contains {found} prediction basis rows, exceeding the V1 limit of {limit}")]
2683 TooManyPredictionBasisReferences {
2684 found: usize,
2686 limit: usize,
2688 },
2689 #[error("retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
2691 TooMuchPredictionText {
2692 found: usize,
2694 limit: usize,
2696 },
2697 #[error("prediction bound accounting overflowed")]
2699 PredictionAccountingOverflow,
2700 #[error("has no versioned measurement contract")]
2702 MissingMeasurementVersion,
2703 #[error(
2705 "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
2706 )]
2707 UnsupportedMeasurementVersion {
2708 found: u32,
2710 },
2711 #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
2713 WrongMeasurementIdentity,
2714 #[error("measurement contract has no `clips` map")]
2716 MissingClips,
2717 #[error("measurement contract has no `material_resource_coverage`")]
2719 MissingMaterialResourceCoverage,
2720 #[error("measurement contract has no `material_definitions` array")]
2722 MissingMaterialDefinitions,
2723 #[error("measurement contract has no `textures` array")]
2725 MissingTextures,
2726 #[error("measurement contract has no `images` array")]
2728 MissingImages,
2729 #[error("measurement contract has no `skeleton_source_coverage`")]
2731 MissingSkeletonSourceCoverage,
2732 #[error("measurement contract has no `skeleton_nodes` array")]
2734 MissingSkeletonNodes,
2735 #[error("measurement contract has no `skins` array")]
2737 MissingSkins,
2738 #[error("measurement contract has no `mesh_definitions` array")]
2740 MissingMeshDefinitions,
2741 #[error("measurement contract has no `node_instances` array")]
2743 MissingNodeInstances,
2744 #[error("measurement contract has no `scenes` array")]
2746 MissingScenes,
2747 #[error("has invalid measurements shape: {reason}")]
2750 InvalidMeasurementsShape {
2751 reason: String,
2753 },
2754 #[error("has invalid measurements: {source}")]
2756 InvalidMeasurements {
2757 #[source]
2759 source: MeasurementContractError,
2760 },
2761}
2762
2763impl MeasurementReportError {
2764 pub fn file_index(&self) -> Option<usize> {
2768 match self {
2769 Self::File { file_index, .. } => Some(*file_index),
2770 _ => None,
2771 }
2772 }
2773
2774 fn file(file_index: usize, source: MeasurementFileError) -> Self {
2775 Self::File { file_index, source }
2776 }
2777}
2778
2779fn prediction_file_error(
2780 file_index: usize,
2781 source: MeasurementFileError,
2782) -> MeasurementReportError {
2783 MeasurementReportError::file(file_index, source)
2784}
2785
2786fn decode_prediction_phase_file(
2787 command: &str,
2788 file_index: usize,
2789 raw: &RawValue,
2790) -> Result<MeasurementFileInput, MeasurementReportError> {
2791 let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
2792 prediction_file_error(
2793 file_index,
2794 MeasurementFileError::InvalidFileShape {
2795 reason: source.to_string(),
2796 },
2797 )
2798 })?;
2799
2800 if command == "measure" {
2801 if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
2802 return Err(prediction_file_error(
2803 file_index,
2804 MeasurementFileError::UnexpectedPredictionProvenance,
2805 ));
2806 }
2807 if wire.checks.is_some() {
2808 return Err(prediction_file_error(
2809 file_index,
2810 MeasurementFileError::UnexpectedChecks,
2811 ));
2812 }
2813 return Ok(MeasurementFileInput {
2814 path: wire.path,
2815 input: wire.input,
2816 measurements: wire.measurements,
2817 prediction_provenance: RequiredNullable::Missing,
2818 checks: None,
2819 });
2820 }
2821
2822 if wire
2823 .checks
2824 .as_ref()
2825 .is_some_and(|checks| checks.len() > OUTPUT_V10_MAX_CHECKS_PER_FILE)
2826 {
2827 return Err(prediction_file_error(
2828 file_index,
2829 MeasurementFileError::TooManyChecks {
2830 found: wire.checks.as_ref().map_or(0, Vec::len),
2831 limit: OUTPUT_V10_MAX_CHECKS_PER_FILE,
2832 },
2833 ));
2834 }
2835
2836 if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
2837 return Err(prediction_file_error(
2838 file_index,
2839 MeasurementFileError::MissingPredictionProvenance,
2840 ));
2841 }
2842
2843 let prediction_provenance = match wire.prediction_provenance {
2844 RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
2845 RequiredNullable::Present(None) => RequiredNullable::Present(None),
2846 RequiredNullable::Present(Some(raw)) => {
2847 let provenance = decode_prediction_provenance_v1(raw.get()).map_err(|error| {
2848 prediction_file_error(
2849 file_index,
2850 match error {
2851 PredictionDecodeError::Shape(source) => {
2852 MeasurementFileError::InvalidPredictionProvenanceShape {
2853 reason: source.to_string(),
2854 }
2855 }
2856 PredictionDecodeError::Semantic(source) => {
2857 MeasurementFileError::InvalidPredictionProvenance { source }
2858 }
2859 PredictionDecodeError::TooManyFileFacets
2860 | PredictionDecodeError::TooManyFileBasisReferences => {
2861 unreachable!("provenance decoding cannot consume prediction budgets")
2862 }
2863 },
2864 )
2865 })?;
2866 RequiredNullable::Present(Some(provenance))
2867 }
2868 };
2869 let mut decoded_facets = 0usize;
2870 let mut decoded_references = 0usize;
2871 let mut decoded_text = match &prediction_provenance {
2872 RequiredNullable::Present(Some(provenance)) => {
2873 provenance.retained_text_bytes().map_err(|source| {
2874 prediction_file_error(
2875 file_index,
2876 MeasurementFileError::InvalidPredictionProvenance { source },
2877 )
2878 })?
2879 }
2880 RequiredNullable::Missing | RequiredNullable::Present(None) => 0,
2881 };
2882 let provenance_for_checks = match &prediction_provenance {
2883 RequiredNullable::Present(provenance) => provenance.as_ref(),
2884 RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
2885 };
2886 let checks = wire
2887 .checks
2888 .map(|raw_checks| {
2889 let mut checks = Vec::with_capacity(raw_checks.len());
2890 for (check_index, raw) in raw_checks.into_iter().enumerate() {
2891 let wire: PredictionCheckWireInput =
2892 serde_json::from_str(raw.get()).map_err(|source| {
2893 prediction_file_error(
2894 file_index,
2895 MeasurementFileError::InvalidPredictionShape {
2896 check_index,
2897 reason: source.to_string(),
2898 },
2899 )
2900 })?;
2901 if provenance_for_checks.is_none() && wire.prediction.is_some() {
2902 return Err(prediction_file_error(
2903 file_index,
2904 MeasurementFileError::PredictionWithoutProvenance { check_index },
2905 ));
2906 }
2907 if (wire.selection == SelectionState::Unselected
2908 || wire.configuration == ConfigurationState::Disabled
2909 || wire.applicability == Applicability::NotApplicable)
2910 && wire.prediction.is_some()
2911 {
2912 return Err(prediction_file_error(
2913 file_index,
2914 MeasurementFileError::InvalidPredictionLifecycle {
2915 check_index,
2916 reason: "inactive check must have empty output",
2917 },
2918 ));
2919 }
2920 let prediction = wire
2921 .prediction
2922 .map(|raw| {
2923 decode_engine_prediction_v1(
2924 raw.get(),
2925 PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
2926 PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
2927 .saturating_sub(decoded_references),
2928 )
2929 .map_err(|error| {
2930 prediction_file_error(
2931 file_index,
2932 match error {
2933 PredictionDecodeError::Shape(source) => {
2934 MeasurementFileError::InvalidPredictionShape {
2935 check_index,
2936 reason: source.to_string(),
2937 }
2938 }
2939 PredictionDecodeError::Semantic(source) => {
2940 MeasurementFileError::InvalidPrediction {
2941 check_index,
2942 source,
2943 }
2944 }
2945 PredictionDecodeError::TooManyFileFacets => {
2946 MeasurementFileError::TooManyPredictionFacets {
2947 found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
2948 limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
2949 }
2950 }
2951 PredictionDecodeError::TooManyFileBasisReferences => {
2952 MeasurementFileError::TooManyPredictionBasisReferences {
2953 found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
2954 limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
2955 }
2956 }
2957 },
2958 )
2959 })
2960 })
2961 .transpose()?;
2962 let check = PredictionCheckInput {
2963 check_id: wire.check_id,
2964 selection: wire.selection,
2965 configuration: wire.configuration,
2966 applicability: wire.applicability,
2967 evaluation: wire.evaluation,
2968 findings: wire.findings,
2969 evaluated_scopes: wire.evaluated_scopes,
2970 gaps: wire.gaps,
2971 prediction,
2972 };
2973 check
2974 .validate(check_index, provenance_for_checks)
2975 .map_err(|source| prediction_file_error(file_index, source))?;
2976 if let Some(prediction) = &check.prediction {
2977 decoded_facets = decoded_facets
2978 .checked_add(prediction.facets().len())
2979 .ok_or_else(|| {
2980 prediction_file_error(
2981 file_index,
2982 MeasurementFileError::PredictionAccountingOverflow,
2983 )
2984 })?;
2985 if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
2986 return Err(prediction_file_error(
2987 file_index,
2988 MeasurementFileError::TooManyPredictionFacets {
2989 found: decoded_facets,
2990 limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
2991 },
2992 ));
2993 }
2994 decoded_references = decoded_references
2995 .checked_add(prediction.basis_reference_count())
2996 .ok_or_else(|| {
2997 prediction_file_error(
2998 file_index,
2999 MeasurementFileError::PredictionAccountingOverflow,
3000 )
3001 })?;
3002 if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3003 return Err(prediction_file_error(
3004 file_index,
3005 MeasurementFileError::TooManyPredictionBasisReferences {
3006 found: decoded_references,
3007 limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3008 },
3009 ));
3010 }
3011 decoded_text = decoded_text
3012 .checked_add(prediction.retained_text_bytes().map_err(|source| {
3013 prediction_file_error(
3014 file_index,
3015 MeasurementFileError::InvalidPrediction {
3016 check_index,
3017 source,
3018 },
3019 )
3020 })?)
3021 .ok_or_else(|| {
3022 prediction_file_error(
3023 file_index,
3024 MeasurementFileError::PredictionAccountingOverflow,
3025 )
3026 })?;
3027 if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3028 return Err(prediction_file_error(
3029 file_index,
3030 MeasurementFileError::TooMuchPredictionText {
3031 found: decoded_text,
3032 limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3033 },
3034 ));
3035 }
3036 }
3037 checks.push(check);
3038 }
3039 Ok(checks)
3040 })
3041 .transpose()?;
3042 Ok(MeasurementFileInput {
3043 path: wire.path,
3044 input: wire.input,
3045 measurements: wire.measurements,
3046 prediction_provenance,
3047 checks,
3048 })
3049}
3050
3051fn validate_prediction_phase_file(
3052 command: &str,
3053 file_index: usize,
3054 file: &MeasurementFileInput,
3055) -> Result<(usize, usize), MeasurementReportError> {
3056 let mut available = 0usize;
3057 let mut unavailable = 0usize;
3058 match command {
3059 "measure" => {
3060 if !matches!(file.prediction_provenance, RequiredNullable::Missing) {
3061 return Err(prediction_file_error(
3062 file_index,
3063 MeasurementFileError::UnexpectedPredictionProvenance,
3064 ));
3065 }
3066 if file.checks.is_some() {
3067 return Err(prediction_file_error(
3068 file_index,
3069 MeasurementFileError::UnexpectedChecks,
3070 ));
3071 }
3072 }
3073 "lint" => {
3074 let provenance = match &file.prediction_provenance {
3075 RequiredNullable::Missing => {
3076 return Err(prediction_file_error(
3077 file_index,
3078 MeasurementFileError::MissingPredictionProvenance,
3079 ));
3080 }
3081 RequiredNullable::Present(provenance) => provenance.as_ref(),
3082 };
3083 let checks = file.checks.as_ref().ok_or_else(|| {
3084 prediction_file_error(file_index, MeasurementFileError::MissingChecks)
3085 })?;
3086 if checks.len() > OUTPUT_V10_MAX_CHECKS_PER_FILE {
3087 return Err(prediction_file_error(
3088 file_index,
3089 MeasurementFileError::TooManyChecks {
3090 found: checks.len(),
3091 limit: OUTPUT_V10_MAX_CHECKS_PER_FILE,
3092 },
3093 ));
3094 }
3095 if let Some(provenance) = provenance {
3096 provenance.validate().map_err(|source| {
3097 prediction_file_error(
3098 file_index,
3099 MeasurementFileError::InvalidPredictionProvenance { source },
3100 )
3101 })?;
3102 let input = file.input.as_ref().ok_or_else(|| {
3103 prediction_file_error(file_index, MeasurementFileError::MissingInput)
3104 })?;
3105 if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
3106 || input.bytes != Some(provenance.raw_source().primary_input().bytes())
3107 {
3108 return Err(prediction_file_error(
3109 file_index,
3110 MeasurementFileError::PredictionPrimaryInputMismatch,
3111 ));
3112 }
3113 }
3114
3115 let mut facets = 0usize;
3116 let mut references = 0usize;
3117 let mut text = provenance
3118 .map(PredictionProvenanceV1::retained_text_bytes)
3119 .transpose()
3120 .map_err(|source| {
3121 prediction_file_error(
3122 file_index,
3123 MeasurementFileError::InvalidPredictionProvenance { source },
3124 )
3125 })?
3126 .unwrap_or(0);
3127 for (check_index, check) in checks.iter().enumerate() {
3128 if let Some(prediction) = &check.prediction {
3129 facets = facets
3130 .checked_add(prediction.facets().len())
3131 .ok_or_else(|| {
3132 prediction_file_error(
3133 file_index,
3134 MeasurementFileError::PredictionAccountingOverflow,
3135 )
3136 })?;
3137 references = references
3138 .checked_add(prediction.basis_reference_count())
3139 .ok_or_else(|| {
3140 prediction_file_error(
3141 file_index,
3142 MeasurementFileError::PredictionAccountingOverflow,
3143 )
3144 })?;
3145 text = text
3146 .checked_add(prediction.retained_text_bytes().map_err(|source| {
3147 prediction_file_error(
3148 file_index,
3149 MeasurementFileError::InvalidPrediction {
3150 check_index,
3151 source,
3152 },
3153 )
3154 })?)
3155 .ok_or_else(|| {
3156 prediction_file_error(
3157 file_index,
3158 MeasurementFileError::PredictionAccountingOverflow,
3159 )
3160 })?;
3161 for facet in prediction.facets() {
3162 match facet.state() {
3163 EnginePredictionFacetStateV1::Available => {
3164 available = available.checked_add(1).ok_or(
3165 MeasurementReportError::PredictionFacetSummaryMismatch,
3166 )?;
3167 }
3168 EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
3169 unavailable = unavailable.checked_add(1).ok_or(
3170 MeasurementReportError::PredictionFacetSummaryMismatch,
3171 )?;
3172 }
3173 }
3174 }
3175 }
3176 }
3177 if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
3178 return Err(prediction_file_error(
3179 file_index,
3180 MeasurementFileError::TooManyPredictionFacets {
3181 found: facets,
3182 limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3183 },
3184 ));
3185 }
3186 if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3187 return Err(prediction_file_error(
3188 file_index,
3189 MeasurementFileError::TooManyPredictionBasisReferences {
3190 found: references,
3191 limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3192 },
3193 ));
3194 }
3195 if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3196 return Err(prediction_file_error(
3197 file_index,
3198 MeasurementFileError::TooMuchPredictionText {
3199 found: text,
3200 limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3201 },
3202 ));
3203 }
3204 }
3205 _ => unreachable!("command was validated before prediction phase"),
3206 }
3207 Ok((available, unavailable))
3208}
3209
3210fn validate_prediction_summary(
3211 command: &str,
3212 summary: Option<&MeasurementReportSummaryInput>,
3213 available: usize,
3214 unavailable: usize,
3215) -> Result<(), MeasurementReportError> {
3216 let summary = summary.and_then(|summary| summary.prediction_facets.as_ref());
3217 match (command, summary) {
3218 ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
3219 ("measure", None) => Ok(()),
3220 ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
3221 ("lint", Some(summary))
3222 if summary.available != available
3223 || summary.required_prediction_unavailable != unavailable =>
3224 {
3225 Err(MeasurementReportError::PredictionFacetSummaryMismatch)
3226 }
3227 ("lint", Some(_)) => Ok(()),
3228 _ => unreachable!("command was validated before prediction summary"),
3229 }
3230}
3231
3232fn validate_prediction_summary_presence(
3233 command: &str,
3234 summary: Option<&MeasurementReportSummaryInput>,
3235) -> Result<(), MeasurementReportError> {
3236 match (
3237 command,
3238 summary.and_then(|summary| summary.prediction_facets.as_ref()),
3239 ) {
3240 ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
3241 ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
3242 ("measure", None) | ("lint", Some(_)) => Ok(()),
3243 _ => unreachable!("command was validated before prediction summary"),
3244 }
3245}
3246
3247impl PredictionCheckInput {
3248 fn validate(
3249 &self,
3250 check_index: usize,
3251 provenance: Option<&PredictionProvenanceV1>,
3252 ) -> Result<(), MeasurementFileError> {
3253 let gap_refs = self
3254 .gaps
3255 .iter()
3256 .map(|gap| CheckEvaluationGapRef {
3257 code: &gap.code,
3258 scope: gap.scope.as_ref(),
3259 })
3260 .collect::<Vec<_>>();
3261 let finding_check_ids = self
3262 .findings
3263 .iter()
3264 .map(|finding| finding.check_id.as_str())
3265 .collect::<Vec<_>>();
3266 let prediction_scopes = self
3267 .prediction
3268 .as_ref()
3269 .into_iter()
3270 .flat_map(crate::prediction::EnginePredictionV1::facets)
3271 .map(|facet| facet.scope())
3272 .collect::<Vec<_>>();
3273 let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
3274 check_id: &self.check_id,
3275 selection: self.selection,
3276 configuration: self.configuration,
3277 applicability: self.applicability,
3278 finding_check_ids: &finding_check_ids,
3279 evaluated_scopes: &self.evaluated_scopes,
3280 gaps: &gap_refs,
3281 prediction_scopes: &prediction_scopes,
3282 has_prediction: self.prediction.is_some(),
3283 prediction_has_required_unavailable: self
3284 .prediction
3285 .as_ref()
3286 .is_some_and(crate::prediction::EnginePredictionV1::has_required_unavailable),
3287 })
3288 .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
3289 check_index,
3290 reason: error.reason(),
3291 })?;
3292 if self.evaluation != derived {
3293 return Err(MeasurementFileError::InvalidPredictionLifecycle {
3294 check_index,
3295 reason: "evaluation does not match completed and missing prediction work",
3296 });
3297 }
3298
3299 let Some(prediction) = &self.prediction else {
3300 if self
3301 .findings
3302 .iter()
3303 .any(|finding| finding.prediction_scope.is_some())
3304 {
3305 return Err(MeasurementFileError::InvalidPredictionLifecycle {
3306 check_index,
3307 reason: "finding has prediction_scope without prediction",
3308 });
3309 }
3310 return Ok(());
3311 };
3312 let provenance =
3313 provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
3314 prediction
3315 .validate_against_provenance(provenance)
3316 .map_err(|source| MeasurementFileError::InvalidPrediction {
3317 check_index,
3318 source,
3319 })?;
3320 for facet in prediction.facets() {
3321 let evaluated = self
3322 .evaluated_scopes
3323 .iter()
3324 .filter(|scope| *scope == facet.scope())
3325 .count();
3326 let duplicated_gap = self
3327 .gaps
3328 .iter()
3329 .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
3330 match facet.state() {
3331 EnginePredictionFacetStateV1::Available if evaluated != 1 => {
3332 return Err(MeasurementFileError::InvalidPredictionLifecycle {
3333 check_index,
3334 reason: "available facet scope must occur exactly once in evaluated_scopes",
3335 });
3336 }
3337 EnginePredictionFacetStateV1::RequiredPredictionUnavailable
3338 if evaluated != 0 || duplicated_gap =>
3339 {
3340 return Err(MeasurementFileError::InvalidPredictionLifecycle {
3341 check_index,
3342 reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
3343 });
3344 }
3345 _ => {}
3346 }
3347 }
3348 for finding in &self.findings {
3349 let Some(scope) = &finding.prediction_scope else {
3350 return Err(MeasurementFileError::InvalidPredictionLifecycle {
3351 check_index,
3352 reason: "prediction-backed finding must carry prediction_scope",
3353 });
3354 };
3355 if prediction
3356 .facets()
3357 .iter()
3358 .filter(|facet| {
3359 facet.scope() == scope
3360 && facet.state() == EnginePredictionFacetStateV1::Available
3361 })
3362 .count()
3363 != 1
3364 {
3365 return Err(MeasurementFileError::InvalidPredictionLifecycle {
3366 check_index,
3367 reason: "finding prediction_scope must name one available facet",
3368 });
3369 }
3370 }
3371 Ok(())
3372 }
3373}
3374
3375impl MeasurementReportInput {
3376 pub fn read_from(reader: impl Read) -> Result<Self, MeasurementReportReadError> {
3388 Self::read_from_with_limit(reader, OUTPUT_V10_MAX_REPORT_BYTES)
3389 }
3390
3391 fn read_from_with_limit(
3392 reader: impl Read,
3393 limit: u64,
3394 ) -> Result<Self, MeasurementReportReadError> {
3395 let mut bounded = reader.take(limit + 1);
3396 let mut bytes = Vec::new();
3397 bounded
3398 .read_to_end(&mut bytes)
3399 .map_err(|source| MeasurementReportReadError::Io { source })?;
3400 if bytes.len() as u64 > limit {
3401 return Err(MeasurementReportReadError::ReportTooLarge { limit });
3402 }
3403 serde_json::from_slice(&bytes)
3404 .map_err(|source| MeasurementReportReadError::InvalidJson { source })
3405 }
3406
3407 pub fn file_count(&self) -> Option<usize> {
3413 self.files.as_ref().map(Vec::len)
3414 }
3415
3416 pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
3427 match self.schema_version {
3428 Some(OUTPUT_SCHEMA_VERSION) => {}
3429 Some(found) => {
3430 return Err(MeasurementReportError::UnsupportedOutputVersion { found });
3431 }
3432 None => return Err(MeasurementReportError::MissingOutputVersion),
3433 }
3434 if self.schema.as_deref() != Some(OUTPUT_SCHEMA_ID) {
3435 return Err(MeasurementReportError::WrongOutputIdentity);
3436 }
3437 let command = match self.command.as_deref() {
3438 Some(command @ ("measure" | "lint")) => command,
3439 Some(command) => {
3440 return Err(MeasurementReportError::UnsupportedCommand {
3441 command: command.to_owned(),
3442 });
3443 }
3444 None => return Err(MeasurementReportError::MissingCommand),
3445 };
3446 if let Some(field) = self.extra.keys().next() {
3447 return Err(MeasurementReportError::UnknownOutputField {
3448 field: field.clone(),
3449 });
3450 }
3451 if self._tool.is_none() {
3452 return Err(MeasurementReportError::MissingTool);
3453 }
3454 validate_prediction_summary_presence(command, self.summary.as_ref())?;
3455 let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
3456 if files.len() > OUTPUT_V10_MAX_FILES {
3457 return Err(MeasurementReportError::TooManyFiles {
3458 found: files.len(),
3459 limit: OUTPUT_V10_MAX_FILES,
3460 });
3461 }
3462 let mut available = 0usize;
3463 let mut unavailable = 0usize;
3464 let mut decoded_files = Vec::with_capacity(files.len());
3465 for (file_index, raw) in files.into_iter().enumerate() {
3466 let file = decode_prediction_phase_file(command, file_index, &raw)?;
3467 let (file_available, file_unavailable) =
3468 validate_prediction_phase_file(command, file_index, &file)?;
3469 available = available
3470 .checked_add(file_available)
3471 .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
3472 unavailable = unavailable
3473 .checked_add(file_unavailable)
3474 .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
3475 decoded_files.push(file);
3476 }
3477 validate_prediction_summary(command, self.summary.as_ref(), available, unavailable)?;
3478 let parsed = decoded_files
3479 .into_iter()
3480 .enumerate()
3481 .map(|(file_index, file)| {
3482 let path = file.path.ok_or_else(|| {
3483 MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
3484 })?;
3485 let input = file.input.ok_or_else(|| {
3486 MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
3487 })?;
3488 let sha256 = input.sha256.ok_or_else(|| {
3489 MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
3490 })?;
3491 if sha256.len() != 64
3492 || !sha256
3493 .bytes()
3494 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
3495 {
3496 return Err(MeasurementReportError::file(
3497 file_index,
3498 MeasurementFileError::InvalidSha256,
3499 ));
3500 }
3501 let bytes = input.bytes.ok_or_else(|| {
3502 MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
3503 })?;
3504 let measurements = file.measurements.ok_or_else(|| {
3505 MeasurementReportError::file(
3506 file_index,
3507 MeasurementFileError::MissingMeasurements,
3508 )
3509 })?;
3510 let measurements = decode_measurement_payload(&measurements).map_err(|source| {
3511 MeasurementReportError::file(
3512 file_index,
3513 MeasurementFileError::InvalidMeasurementsShape {
3514 reason: source.to_string(),
3515 },
3516 )
3517 })?;
3518 match measurements.schema_version {
3519 Some(MEASUREMENTS_SCHEMA_VERSION) => {}
3520 Some(found) => {
3521 return Err(MeasurementReportError::file(
3522 file_index,
3523 MeasurementFileError::UnsupportedMeasurementVersion { found },
3524 ));
3525 }
3526 None => {
3527 return Err(MeasurementReportError::file(
3528 file_index,
3529 MeasurementFileError::MissingMeasurementVersion,
3530 ));
3531 }
3532 }
3533 if measurements.schema.as_deref() != Some(MEASUREMENTS_SCHEMA_ID) {
3534 return Err(MeasurementReportError::file(
3535 file_index,
3536 MeasurementFileError::WrongMeasurementIdentity,
3537 ));
3538 }
3539 let clips = measurements.clips.ok_or_else(|| {
3540 MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
3541 })?;
3542 let material_resource_coverage =
3543 measurements.material_resource_coverage.ok_or_else(|| {
3544 MeasurementReportError::file(
3545 file_index,
3546 MeasurementFileError::MissingMaterialResourceCoverage,
3547 )
3548 })?;
3549 let material_definitions = measurements.material_definitions.ok_or_else(|| {
3550 MeasurementReportError::file(
3551 file_index,
3552 MeasurementFileError::MissingMaterialDefinitions,
3553 )
3554 })?;
3555 let textures = measurements.textures.ok_or_else(|| {
3556 MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
3557 })?;
3558 let images = measurements.images.ok_or_else(|| {
3559 MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
3560 })?;
3561 let skeleton_source_coverage =
3562 measurements.skeleton_source_coverage.ok_or_else(|| {
3563 MeasurementReportError::file(
3564 file_index,
3565 MeasurementFileError::MissingSkeletonSourceCoverage,
3566 )
3567 })?;
3568 let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
3569 MeasurementReportError::file(
3570 file_index,
3571 MeasurementFileError::MissingSkeletonNodes,
3572 )
3573 })?;
3574 let skeleton_nodes = skeleton_nodes
3575 .into_iter()
3576 .enumerate()
3577 .map(|(offset, node)| match node {
3578 SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
3579 SkeletonNodeMeasurementInput::Earlier { .. } => {
3580 Err(MeasurementReportError::file(
3581 file_index,
3582 MeasurementFileError::InvalidMeasurements {
3583 source: MeasurementContractError::InvalidStructure {
3584 path: format!("skeleton_nodes[{offset}]"),
3585 reason: "uses a shape from an earlier measurement contract"
3586 .into(),
3587 },
3588 },
3589 ))
3590 }
3591 })
3592 .collect::<Result<Vec<_>, _>>()?;
3593 let skins = measurements.skins.ok_or_else(|| {
3594 MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
3595 })?;
3596 let skins = skins
3597 .into_iter()
3598 .enumerate()
3599 .map(|(offset, skin)| match skin {
3600 SkinMeasurementInput::Current(skin) => Ok(*skin),
3601 SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
3602 file_index,
3603 MeasurementFileError::InvalidMeasurements {
3604 source: MeasurementContractError::InvalidStructure {
3605 path: format!("skins[{offset}]"),
3606 reason: "uses a shape from an earlier measurement contract"
3607 .into(),
3608 },
3609 },
3610 )),
3611 })
3612 .collect::<Result<Vec<_>, _>>()?;
3613 let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
3614 MeasurementReportError::file(
3615 file_index,
3616 MeasurementFileError::MissingMeshDefinitions,
3617 )
3618 })?;
3619 let node_instances = measurements.node_instances.ok_or_else(|| {
3620 MeasurementReportError::file(
3621 file_index,
3622 MeasurementFileError::MissingNodeInstances,
3623 )
3624 })?;
3625 let scenes = measurements.scenes.ok_or_else(|| {
3626 MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
3627 })?;
3628 let assets = AssetMeasurements {
3629 material_resource_coverage,
3630 material_definitions,
3631 textures,
3632 images,
3633 skeleton_source_coverage,
3634 skeleton_nodes,
3635 skins,
3636 mesh_definitions,
3637 node_instances,
3638 scenes,
3639 default_scene_index: measurements.default_scene_index,
3640 };
3641 let measurements = MeasurementContract::new(clips, assets).map_err(|source| {
3642 MeasurementReportError::file(
3643 file_index,
3644 MeasurementFileError::InvalidMeasurements { source },
3645 )
3646 })?;
3647 Ok((
3648 MeasurementReportFile {
3649 path,
3650 input: InputIdentity { sha256, bytes },
3651 measurements,
3652 },
3653 file.checks.unwrap_or_default(),
3654 ))
3655 })
3656 .collect::<Result<Vec<_>, _>>()?;
3657
3658 for (file_index, (file, checks)) in parsed.iter().enumerate() {
3661 validate_measurement_references_batch(
3662 &file.measurements,
3663 checks
3664 .iter()
3665 .enumerate()
3666 .filter_map(|(check_index, check)| {
3667 check
3668 .prediction
3669 .as_ref()
3670 .map(|prediction| (check_index, prediction))
3671 }),
3672 )
3673 .map_err(|error| {
3674 MeasurementReportError::file(
3675 file_index,
3676 MeasurementFileError::InvalidPrediction {
3677 check_index: error.prediction_index,
3678 source: error.source,
3679 },
3680 )
3681 })?;
3682 }
3683 Ok(parsed.into_iter().map(|(file, _)| file).collect())
3684 }
3685}
3686
3687#[cfg(test)]
3688mod measurement_report_input_tests {
3689 use std::collections::BTreeMap;
3690
3691 use super::*;
3692 use crate::engine_contract::{
3693 EngineFactIdV1, EngineFactStateV1, EngineFactValueV1, EnginePrimarySourceV1,
3694 EngineProfileFactV1, EngineProfileSelectionV1, ResolvedEngineProfileV1,
3695 ResolvedEngineSettingsV1,
3696 };
3697 use crate::evaluation::{CheckOutput, EvaluationScope, EvaluationScopeCode};
3698 use crate::measure::AssetMeasurements;
3699 use crate::prediction::{
3700 EnginePredictionBasisV1, EnginePredictionFacetV1, EnginePredictionV1,
3701 PredictionBasisReferenceV1, PredictionScalarV1, PredictionUnavailableReasonV1,
3702 RawSourceBindingV1,
3703 };
3704 use crate::source_facts::SourceFormatV1;
3705 use crate::{DependencyClosureV1, Document, ResolvedRoles};
3706
3707 fn prediction_test_profile() -> ResolvedEngineProfileV1 {
3708 let all_fact_ids = [
3709 EngineFactIdV1::AcceptedInputs,
3710 EngineFactIdV1::AnimationAddressability,
3711 EngineFactIdV1::AnimationChannelHandling,
3712 EngineFactIdV1::AnimationTargetAddressability,
3713 EngineFactIdV1::AxisConversionControl,
3714 EngineFactIdV1::ConstructHandling,
3715 EngineFactIdV1::ExactAxisConversion,
3716 EngineFactIdV1::ExtensionHandling,
3717 EngineFactIdV1::ResultingHierarchyScale,
3718 EngineFactIdV1::RootMotionAddressability,
3719 EngineFactIdV1::TargetCoordinateBasis,
3720 EngineFactIdV1::TargetLinearUnit,
3721 EngineFactIdV1::UnitConversionControl,
3722 EngineFactIdV1::WholeEndFrameRequired,
3723 ];
3724 let facts = all_fact_ids
3725 .into_iter()
3726 .map(|id| {
3727 let state = if id == EngineFactIdV1::AcceptedInputs {
3728 EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
3729 SourceFormatV1::Glb,
3730 ]))
3731 } else {
3732 EngineFactStateV1::Unknown
3733 };
3734 EngineProfileFactV1::new(id, state)
3735 })
3736 .collect();
3737 ResolvedEngineProfileV1::new(
3738 EngineProfileSelectionV1::new("test", 1, "1", "test-importer").unwrap(),
3739 "urn:animsmith:engine-profile:test:1",
3740 facts,
3741 vec![],
3742 vec![
3743 EnginePrimarySourceV1::new(
3744 "test-source",
3745 "1",
3746 "https://example.invalid/test",
3747 "2026-08-20",
3748 vec![EngineFactIdV1::AcceptedInputs],
3749 vec![],
3750 )
3751 .unwrap(),
3752 ],
3753 )
3754 .unwrap()
3755 }
3756
3757 fn prediction_test_provenance() -> PredictionProvenanceV1 {
3758 let raw: RawSourceBindingV1 = serde_json::from_value(serde_json::json!({
3759 "schema": crate::RAW_SOURCE_FACTS_V1_ID,
3760 "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
3761 "source_format": "glb",
3762 "linear_unit": {
3763 "state": "observed", "value": 1.0, "disposition": "preserved",
3764 "provenance": {"kind": "format_defined"}
3765 },
3766 "coordinate_basis": {
3767 "state": "observed",
3768 "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
3769 "disposition": "preserved", "provenance": {"kind": "format_defined"}
3770 },
3771 "frames_per_second": {
3772 "state": "observed", "value": 30.0, "disposition": "preserved",
3773 "provenance": {"kind": "format_defined"}
3774 },
3775 "clips_coverage": {"state": "complete"},
3776 "constructs_coverage": {"state": "complete"},
3777 "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
3778 "source_skeleton_coverage": "unavailable",
3779 "work": {
3780 "inspected_rows": 0, "retained_rows": 0,
3781 "retained_text_bytes": 0, "max_traversal_depth": 0
3782 }
3783 }))
3784 .unwrap();
3785 let closure = DependencyClosureV1::unavailable(raw.primary_input().clone());
3786 let profile = prediction_test_profile();
3787 let settings = ResolvedEngineSettingsV1::new(&profile, vec![], vec![]).unwrap();
3788 PredictionProvenanceV1::new(profile, SourceFormatV1::Glb, settings, raw, closure).unwrap()
3789 }
3790
3791 fn prediction_test_measurements() -> MeasurementContract {
3792 MeasurementContract::new(BTreeMap::new(), AssetMeasurements::default()).unwrap()
3793 }
3794
3795 fn prediction_test_rig() -> RigInfo {
3796 RigInfo::from_resolved(&Document::default(), &ResolvedRoles::default()).unwrap()
3797 }
3798
3799 fn unavailable_facet(
3800 subject: String,
3801 basis: EnginePredictionBasisV1,
3802 ) -> EnginePredictionFacetV1 {
3803 EnginePredictionFacetV1::required_unavailable(
3804 EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit"))
3805 .subject(subject),
3806 basis,
3807 vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
3808 )
3809 .unwrap()
3810 }
3811
3812 fn unavailable_check(
3813 check_id: &'static str,
3814 provenance: &PredictionProvenanceV1,
3815 facets: Vec<EnginePredictionFacetV1>,
3816 ) -> CheckEvaluation {
3817 let prediction = EnginePredictionV1::new(provenance.identity().clone(), facets).unwrap();
3818 CheckEvaluation::evaluated(
3819 check_id,
3820 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
3821 .with_engine_prediction(prediction),
3822 )
3823 .unwrap()
3824 }
3825
3826 fn lint_file(
3827 provenance: &PredictionProvenanceV1,
3828 checks: Vec<CheckEvaluation>,
3829 ) -> Result<LintFileReport, OutputContractError> {
3830 LintFileReport::new(
3831 "limit.glb",
3832 provenance.raw_source().primary_input().clone(),
3833 prediction_test_rig(),
3834 Some(provenance.clone()),
3835 checks,
3836 prediction_test_measurements(),
3837 )
3838 }
3839
3840 fn validated_lint_wire(
3841 provenance: &PredictionProvenanceV1,
3842 checks: Vec<CheckEvaluation>,
3843 ) -> serde_json::Value {
3844 let file = lint_file(provenance, checks).expect("producer accepts exact N");
3845 let envelope =
3846 LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
3847 .unwrap();
3848 let wire = serde_json::to_value(envelope).unwrap();
3849 let read: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
3850 read.into_files().expect("reader accepts exact N");
3851 wire
3852 }
3853
3854 fn lint_read_error(wire: serde_json::Value) -> MeasurementReportError {
3855 let read: MeasurementReportInput = serde_json::from_value(wire).unwrap();
3856 read.into_files().expect_err("reader must reject N+1")
3857 }
3858
3859 fn prediction_with_retained_text(
3860 provenance: &PredictionProvenanceV1,
3861 retained_text: usize,
3862 ) -> EnginePredictionV1 {
3863 const FIELD_ID_BYTES: usize = 16;
3864 const MAX_VALUE_BYTES: usize = crate::PREDICTION_V1_MAX_TEXT_BYTES;
3865 let fixed = "test:prediction-limit".len()
3866 + PredictionUnavailableReasonV1::ProjectIntentUnavailable
3867 .as_str()
3868 .len();
3869 let remaining = retained_text.checked_sub(fixed).unwrap();
3870 let full_row = FIELD_ID_BYTES + MAX_VALUE_BYTES;
3871 let full_rows = remaining / full_row;
3872 let remainder = remaining % full_row;
3873 let (full_rows, tail_lengths) = if remainder == 0 {
3874 (full_rows, Vec::new())
3875 } else if remainder >= FIELD_ID_BYTES {
3876 (full_rows, vec![remainder - FIELD_ID_BYTES])
3877 } else {
3878 (
3879 full_rows - 1,
3880 vec![0, MAX_VALUE_BYTES - FIELD_ID_BYTES + remainder],
3881 )
3882 };
3883 let mut references = Vec::with_capacity(full_rows + tail_lengths.len());
3884 for index in 0..full_rows {
3885 references.push(
3886 PredictionBasisReferenceV1::project_field(
3887 format!("f{index:015}"),
3888 PredictionScalarV1::text("x".repeat(MAX_VALUE_BYTES)).unwrap(),
3889 )
3890 .unwrap(),
3891 );
3892 }
3893 for length in tail_lengths {
3894 let index = references.len();
3895 references.push(
3896 PredictionBasisReferenceV1::project_field(
3897 format!("f{index:015}"),
3898 PredictionScalarV1::text("x".repeat(length)).unwrap(),
3899 )
3900 .unwrap(),
3901 );
3902 }
3903 let basis = EnginePredictionBasisV1::new(references).unwrap();
3904 let facet = EnginePredictionFacetV1::required_unavailable(
3905 EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit")),
3906 basis,
3907 vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
3908 )
3909 .unwrap();
3910 let prediction =
3911 EnginePredictionV1::new(provenance.identity().clone(), vec![facet]).unwrap();
3912 assert_eq!(prediction.retained_text_bytes().unwrap(), retained_text);
3913 prediction
3914 }
3915
3916 #[test]
3917 fn report_reader_enforces_the_byte_cap_before_json_parsing() {
3918 let bytes = br#"{"schema_version":10,"tool":{}}"#;
3919 let report =
3920 MeasurementReportInput::read_from_with_limit(bytes.as_slice(), bytes.len() as u64)
3921 .expect("exact N must parse");
3922 assert_eq!(report.schema_version, Some(10));
3923
3924 assert!(matches!(
3925 MeasurementReportInput::read_from_with_limit(
3926 bytes.as_slice(),
3927 bytes.len() as u64 - 1,
3928 ),
3929 Err(MeasurementReportReadError::ReportTooLarge { limit })
3930 if limit == bytes.len() as u64 - 1
3931 ));
3932 }
3933
3934 #[test]
3935 fn prediction_facet_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
3936 let provenance = prediction_test_provenance();
3937 let empty_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
3938 let facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
3939 .map(|index| unavailable_facet(format!("facet-{index:04}"), empty_basis.clone()))
3940 .collect();
3941 let at_limit = unavailable_check("test:facet-limit", &provenance, facets);
3942 let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
3943 let extra = unavailable_check(
3944 "test:facet-extra",
3945 &provenance,
3946 vec![unavailable_facet("facet-extra".into(), empty_basis)],
3947 );
3948
3949 assert_eq!(
3950 lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
3951 OutputContractError::TooManyPredictionFacets {
3952 found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3953 limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3954 }
3955 );
3956
3957 wire["files"][0]["checks"]
3958 .as_array_mut()
3959 .unwrap()
3960 .push(serde_json::to_value(extra).unwrap());
3961 assert_eq!(
3962 lint_read_error(wire),
3963 MeasurementReportError::File {
3964 file_index: 0,
3965 source: MeasurementFileError::TooManyPredictionFacets {
3966 found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3967 limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3968 },
3969 }
3970 );
3971 }
3972
3973 #[test]
3974 fn prediction_basis_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
3975 let provenance = prediction_test_provenance();
3976 let basis = EnginePredictionBasisV1::new(
3977 (0..crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET)
3978 .map(|index| {
3979 PredictionBasisReferenceV1::project_field(
3980 format!("project.field.{index:04}"),
3981 PredictionScalarV1::Null,
3982 )
3983 .unwrap()
3984 })
3985 .collect(),
3986 )
3987 .unwrap();
3988 let facet_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3989 / crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
3990 let facets = (0..facet_count)
3991 .map(|index| unavailable_facet(format!("basis-{index:02}"), basis.clone()))
3992 .collect();
3993 let at_limit = unavailable_check("test:basis-limit", &provenance, facets);
3994 let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
3995 let extra_basis = EnginePredictionBasisV1::new(vec![
3996 PredictionBasisReferenceV1::project_field("project.extra", PredictionScalarV1::Null)
3997 .unwrap(),
3998 ])
3999 .unwrap();
4000 let extra = unavailable_check(
4001 "test:basis-extra",
4002 &provenance,
4003 vec![unavailable_facet("basis-extra".into(), extra_basis)],
4004 );
4005
4006 assert_eq!(
4007 lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
4008 OutputContractError::TooManyPredictionBasisReferences {
4009 found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4010 limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4011 }
4012 );
4013
4014 wire["files"][0]["checks"]
4015 .as_array_mut()
4016 .unwrap()
4017 .push(serde_json::to_value(extra).unwrap());
4018 *wire["files"][0]["checks"]
4019 .as_array_mut()
4020 .unwrap()
4021 .last_mut()
4022 .unwrap()
4023 .get_mut("prediction")
4024 .unwrap()
4025 .get_mut("facets")
4026 .and_then(serde_json::Value::as_array_mut)
4027 .and_then(|facets| facets.first_mut())
4028 .and_then(|facet| facet.get_mut("basis"))
4029 .and_then(|basis| basis.get_mut("references"))
4030 .and_then(serde_json::Value::as_array_mut)
4031 .and_then(|references| references.first_mut())
4032 .unwrap() = serde_json::Value::Null;
4033 assert_eq!(
4034 lint_read_error(wire),
4035 MeasurementReportError::File {
4036 file_index: 0,
4037 source: MeasurementFileError::TooManyPredictionBasisReferences {
4038 found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4039 limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4040 },
4041 }
4042 );
4043 }
4044
4045 #[test]
4046 fn prediction_text_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
4047 let provenance = prediction_test_provenance();
4048 let provenance_text = provenance.retained_text_bytes().unwrap();
4049 let at_limit_prediction = prediction_with_retained_text(
4050 &provenance,
4051 PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE - provenance_text,
4052 );
4053 let at_limit = CheckEvaluation::evaluated(
4054 "test:text-limit",
4055 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
4056 .with_engine_prediction(at_limit_prediction),
4057 )
4058 .unwrap();
4059 let mut wire = validated_lint_wire(&provenance, vec![at_limit]);
4060
4061 let above_limit_prediction = prediction_with_retained_text(
4062 &provenance,
4063 PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1 - provenance_text,
4064 );
4065 let above_limit = CheckEvaluation::evaluated(
4066 "test:text-limit",
4067 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
4068 .with_engine_prediction(above_limit_prediction),
4069 )
4070 .unwrap();
4071 let above_limit_wire = serde_json::to_value(&above_limit).unwrap();
4072 assert_eq!(
4073 lint_file(&provenance, vec![above_limit]).unwrap_err(),
4074 OutputContractError::TooMuchPredictionText {
4075 found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
4076 limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4077 }
4078 );
4079
4080 wire["files"][0]["checks"][0] = above_limit_wire;
4081 assert_eq!(
4082 lint_read_error(wire),
4083 MeasurementReportError::File {
4084 file_index: 0,
4085 source: MeasurementFileError::TooMuchPredictionText {
4086 found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
4087 limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4088 },
4089 }
4090 );
4091 }
4092
4093 fn reader_error(wire: serde_json::Value) -> MeasurementReportError {
4094 serde_json::from_value::<MeasurementReportInput>(wire)
4095 .expect("outer v10 shape remains valid")
4096 .into_files()
4097 .expect_err("mutated report must fail")
4098 }
4099
4100 fn empty_check(check_id: &'static str) -> CheckEvaluation {
4101 CheckEvaluation::evaluated(
4102 check_id,
4103 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
4104 )
4105 .unwrap()
4106 }
4107
4108 #[test]
4109 fn staged_reader_rejects_unknown_root_file_and_check_fields() {
4110 let provenance = prediction_test_provenance();
4111 let wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4112
4113 let mut root = wire.clone();
4114 root["unknown_root"] = serde_json::json!(true);
4115 let bytes = serde_json::to_vec(&root).unwrap();
4116 assert_eq!(
4117 MeasurementReportInput::read_from(bytes.as_slice())
4118 .expect("unknown root fields are retained through the staged read")
4119 .into_files()
4120 .unwrap_err(),
4121 MeasurementReportError::UnknownOutputField {
4122 field: "unknown_root".into(),
4123 }
4124 );
4125
4126 let mut missing_tool = wire.clone();
4127 missing_tool.as_object_mut().unwrap().remove("tool");
4128 assert_eq!(
4129 reader_error(missing_tool),
4130 MeasurementReportError::MissingTool
4131 );
4132
4133 let bare = br#"{"walk":true}"#;
4134 assert_eq!(
4135 MeasurementReportInput::read_from(bare.as_slice())
4136 .expect("unknown root fields remain staged until header validation")
4137 .into_files()
4138 .unwrap_err(),
4139 MeasurementReportError::MissingOutputVersion,
4140 );
4141
4142 let unsupported = br#"{"schema_version":9,"walk":true}"#;
4143 assert_eq!(
4144 MeasurementReportInput::read_from(unsupported.as_slice())
4145 .expect("unknown root fields remain staged until header validation")
4146 .into_files()
4147 .unwrap_err(),
4148 MeasurementReportError::UnsupportedOutputVersion { found: 9 },
4149 );
4150
4151 let mut file = wire.clone();
4152 file["files"][0]["unknown_file"] = serde_json::json!(true);
4153 assert!(matches!(
4154 reader_error(file),
4155 MeasurementReportError::File {
4156 file_index: 0,
4157 source: MeasurementFileError::InvalidFileShape { reason },
4158 } if reason.contains("unknown field `unknown_file`")
4159 ));
4160
4161 let mut check = wire;
4162 check["files"][0]["checks"][0]["unknown_check"] = serde_json::json!(true);
4163 assert!(matches!(
4164 reader_error(check),
4165 MeasurementReportError::File {
4166 file_index: 0,
4167 source: MeasurementFileError::InvalidPredictionShape {
4168 check_index: 0,
4169 reason,
4170 },
4171 } if reason.contains("unknown field `unknown_check`")
4172 ));
4173
4174 let provenance = prediction_test_provenance();
4175 let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4176 summary["summary"]["prediction_facets"]["unknown_prediction_total"] = serde_json::json!(0);
4177 let bytes = serde_json::to_vec(&summary).unwrap();
4178 assert!(matches!(
4179 MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
4180 MeasurementReportReadError::InvalidJson { source }
4181 if source.to_string().contains("unknown field `unknown_prediction_total`")
4182 ));
4183
4184 let provenance = prediction_test_provenance();
4185 let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4186 summary["summary"]["unknown_summary"] = serde_json::json!(0);
4187 let bytes = serde_json::to_vec(&summary).unwrap();
4188 assert!(matches!(
4189 MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
4190 MeasurementReportReadError::InvalidJson { source }
4191 if source.to_string().contains("unknown field `unknown_summary`")
4192 ));
4193 }
4194
4195 #[test]
4196 fn staged_reader_preserves_typed_prediction_semantic_errors() {
4197 let provenance = prediction_test_provenance();
4198 let mut provenance_wire = validated_lint_wire(&provenance, Vec::new());
4199 provenance_wire["files"][0]["prediction_provenance"]["schema"] =
4200 serde_json::json!("urn:changed");
4201 assert!(matches!(
4202 reader_error(provenance_wire),
4203 MeasurementReportError::File {
4204 file_index: 0,
4205 source: MeasurementFileError::InvalidPredictionProvenance {
4206 source: PredictionContractError::InvalidSchema {
4207 field: "provenance.schema",
4208 ..
4209 },
4210 },
4211 }
4212 ));
4213
4214 let basis = EnginePredictionBasisV1::new(vec![
4215 PredictionBasisReferenceV1::project_field(
4216 "test:project",
4217 PredictionScalarV1::Boolean { value: true },
4218 )
4219 .unwrap(),
4220 ])
4221 .unwrap();
4222 let facet = EnginePredictionFacetV1::required_unavailable(
4223 EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
4224 basis,
4225 vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
4226 )
4227 .unwrap();
4228 let prediction_wire = validated_lint_wire(
4229 &provenance,
4230 vec![unavailable_check("test:reader", &provenance, vec![facet])],
4231 );
4232
4233 let mut wrong_emitter = prediction_wire.clone();
4234 wrong_emitter["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
4235 serde_json::json!("member_existence");
4236 assert!(matches!(
4237 reader_error(wrong_emitter),
4238 MeasurementReportError::File {
4239 file_index: 0,
4240 source: MeasurementFileError::InvalidPredictionLifecycle {
4241 check_index: 0,
4242 reason: "prediction facet scope code is invalid for its parent check",
4243 },
4244 }
4245 ));
4246
4247 let mut empty_scope = prediction_wire.clone();
4248 empty_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
4249 serde_json::json!("");
4250 assert!(matches!(
4251 reader_error(empty_scope),
4252 MeasurementReportError::File {
4253 file_index: 0,
4254 source: MeasurementFileError::InvalidPrediction {
4255 check_index: 0,
4256 source: PredictionContractError::InvalidToken {
4257 field: "facet scope code",
4258 ..
4259 },
4260 },
4261 }
4262 ));
4263
4264 let mut prediction_wire = prediction_wire;
4265 prediction_wire["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["identity"]
4266 ["bytes"] = serde_json::json!(0);
4267 assert!(matches!(
4268 reader_error(prediction_wire),
4269 MeasurementReportError::File {
4270 file_index: 0,
4271 source: MeasurementFileError::InvalidPrediction {
4272 check_index: 0,
4273 source: PredictionContractError::IdentityMismatch {
4274 contract: "engine prediction basis v1",
4275 },
4276 },
4277 }
4278 ));
4279 }
4280
4281 #[test]
4282 fn staged_reader_uses_the_authoritative_check_lifecycle_without_prediction() {
4283 let provenance = prediction_test_provenance();
4284 let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4285
4286 for (field, state) in [
4287 ("selection", "unselected"),
4288 ("configuration", "disabled"),
4289 ("applicability", "not_applicable"),
4290 ] {
4291 let mut inactive = base.clone();
4292 inactive["files"][0]["checks"][0][field] = serde_json::json!(state);
4293 assert!(matches!(
4294 reader_error(inactive),
4295 MeasurementReportError::File {
4296 file_index: 0,
4297 source: MeasurementFileError::InvalidPredictionLifecycle {
4298 check_index: 0,
4299 reason: "evaluation does not match completed and missing prediction work",
4300 },
4301 }
4302 ));
4303 }
4304
4305 let mut inactive = base.clone();
4306 inactive["files"][0]["checks"][0]["selection"] = serde_json::json!("unselected");
4307 inactive["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
4308 serde_json::from_value::<MeasurementReportInput>(inactive)
4309 .unwrap()
4310 .into_files()
4311 .expect("empty inactive record is valid");
4312
4313 let mut not_evaluated = base.clone();
4314 not_evaluated["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
4315 "code": "test:missing",
4316 "message": "missing",
4317 }]);
4318 not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
4319 serde_json::from_value::<MeasurementReportInput>(not_evaluated.clone())
4320 .unwrap()
4321 .into_files()
4322 .expect("missing-only active record derives not_evaluated");
4323 not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
4324 assert!(matches!(
4325 reader_error(not_evaluated),
4326 MeasurementReportError::File {
4327 source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4328 ..
4329 }
4330 ));
4331
4332 let mut partial = base.clone();
4333 partial["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
4334 "code": "test:missing",
4335 "message": "missing",
4336 }]);
4337 partial["files"][0]["checks"][0]["evaluated_scopes"] =
4338 serde_json::json!([{ "code": "test:completed" }]);
4339 partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4340 serde_json::from_value::<MeasurementReportInput>(partial.clone())
4341 .unwrap()
4342 .into_files()
4343 .expect("mixed active record derives partial");
4344 partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
4345 assert!(matches!(
4346 reader_error(partial),
4347 MeasurementReportError::File {
4348 source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4349 ..
4350 }
4351 ));
4352
4353 let mut wrong_complete = base;
4354 wrong_complete["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4355 assert!(matches!(
4356 reader_error(wrong_complete),
4357 MeasurementReportError::File {
4358 source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4359 ..
4360 }
4361 ));
4362 }
4363
4364 #[test]
4365 fn staged_reader_rejects_invalid_scope_gap_and_finding_shapes() {
4366 let provenance = prediction_test_provenance();
4367 let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4368
4369 let mut empty_scope = base.clone();
4370 empty_scope["files"][0]["checks"][0]["evaluated_scopes"] =
4371 serde_json::json!([{ "code": "" }]);
4372 assert!(matches!(
4373 reader_error(empty_scope),
4374 MeasurementReportError::File {
4375 source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4376 ..
4377 }
4378 ));
4379
4380 let mut malformed_gap = base.clone();
4381 malformed_gap["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
4382 "code": "",
4383 "message": "missing",
4384 }]);
4385 malformed_gap["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
4386 assert!(matches!(
4387 reader_error(malformed_gap),
4388 MeasurementReportError::File {
4389 source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4390 ..
4391 }
4392 ));
4393
4394 let mut incomplete_finding = base;
4395 incomplete_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([{
4396 "check_id": "test:reader",
4397 }]);
4398 assert!(matches!(
4399 reader_error(incomplete_finding),
4400 MeasurementReportError::File {
4401 source: MeasurementFileError::InvalidPredictionShape { check_index: 0, .. },
4402 ..
4403 }
4404 ));
4405 }
4406
4407 #[test]
4408 fn staged_reader_stops_at_the_first_files_lifecycle_failure() {
4409 let provenance = prediction_test_provenance();
4410 let mut wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4411 let mut later_file = wire["files"][0].clone();
4412 later_file["prediction_provenance"]["schema"] = serde_json::json!("urn:changed");
4413 wire["files"].as_array_mut().unwrap().push(later_file);
4414 wire["summary"]["files"] = serde_json::json!(2);
4415 wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4416
4417 assert!(matches!(
4418 reader_error(wire),
4419 MeasurementReportError::File {
4420 file_index: 0,
4421 source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4422 }
4423 ));
4424 }
4425
4426 #[test]
4427 fn staged_reader_stops_at_the_first_checks_lifecycle_failure() {
4428 let provenance = prediction_test_provenance();
4429 let basis = EnginePredictionBasisV1::new(vec![
4430 PredictionBasisReferenceV1::project_field(
4431 "test:project",
4432 PredictionScalarV1::Boolean { value: true },
4433 )
4434 .unwrap(),
4435 ])
4436 .unwrap();
4437 let facet = EnginePredictionFacetV1::required_unavailable(
4438 EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
4439 basis,
4440 vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
4441 )
4442 .unwrap();
4443 let mut wire = validated_lint_wire(
4444 &provenance,
4445 vec![
4446 empty_check("test:first"),
4447 unavailable_check("test:second", &provenance, vec![facet]),
4448 ],
4449 );
4450 wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4451 wire["files"][0]["checks"][1]["unknown"] = serde_json::json!(true);
4452
4453 assert!(matches!(
4454 reader_error(wire),
4455 MeasurementReportError::File {
4456 file_index: 0,
4457 source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4458 }
4459 ));
4460 }
4461
4462 #[test]
4463 fn v11_nested_version_is_rejected_before_current_shape_decode() {
4464 let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
4465 "schema_version": OUTPUT_SCHEMA_VERSION,
4466 "schema": OUTPUT_SCHEMA_ID,
4467 "tool": {},
4468 "command": "measure",
4469 "files": [{
4470 "path": "measurements-v11.json",
4471 "input": { "sha256": "0".repeat(64), "bytes": 0 },
4472 "rig": {},
4473 "measurements": {
4474 "schema_version": 11,
4475 "schema": "urn:animsmith:schema:measurements:11",
4476 "skeleton_nodes": [{
4477 "node_index": 0,
4478 "scene_root_indices": [],
4479 "local_rest": {
4480 "kind": "trs",
4481 "translation_m": [0.0, 0.0, 0.0],
4482 "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
4483 "scale": [1.0, 1.0, 1.0]
4484 },
4485 "rest_world_matrix": [
4486 1.0, 0.0, 0.0, 0.0,
4487 0.0, 1.0, 0.0, 0.0,
4488 0.0, 0.0, 1.0, 0.0,
4489 0.0, 0.0, 0.0, 1.0
4490 ]
4491 }],
4492 "skins": [{ "skin_index": 0 }]
4493 }
4494 }]
4495 }))
4496 .expect("unsupported payload shapes remain decodable for version rejection");
4497
4498 assert!(matches!(
4499 report.into_files(),
4500 Err(MeasurementReportError::File {
4501 file_index: 0,
4502 source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
4503 })
4504 ));
4505 }
4506}
4507
4508#[derive(Debug, Clone, Serialize)]
4509struct FileEvidence {
4510 path: String,
4511 input: InputIdentity,
4512 rig: RigInfo,
4513 measurements: MeasurementContract,
4514}
4515
4516impl FileEvidence {
4517 fn new(
4518 path: impl Into<String>,
4519 input: InputIdentity,
4520 rig: RigInfo,
4521 measurements: MeasurementContract,
4522 ) -> Self {
4523 Self {
4524 path: path.into(),
4525 input,
4526 rig,
4527 measurements,
4528 }
4529 }
4530}
4531
4532#[derive(Debug, Clone, Serialize)]
4534pub struct MeasureFileReport {
4535 #[serde(flatten)]
4536 evidence: FileEvidence,
4537}
4538
4539impl MeasureFileReport {
4540 pub fn new(
4542 path: impl Into<String>,
4543 input: InputIdentity,
4544 rig: RigInfo,
4545 measurements: MeasurementContract,
4546 ) -> Self {
4547 Self {
4548 evidence: FileEvidence::new(path, input, rig, measurements),
4549 }
4550 }
4551
4552 pub fn path(&self) -> &str {
4554 &self.evidence.path
4555 }
4556
4557 pub fn input(&self) -> &InputIdentity {
4559 &self.evidence.input
4560 }
4561
4562 pub fn measurements(&self) -> &MeasurementContract {
4564 &self.evidence.measurements
4565 }
4566}
4567
4568#[derive(Debug, Clone, Serialize)]
4570pub struct LintFileReport {
4571 #[serde(flatten)]
4572 evidence: FileEvidence,
4573 prediction_provenance: Option<PredictionProvenanceV1>,
4574 checks: Vec<CheckEvaluation>,
4575}
4576
4577impl LintFileReport {
4578 pub fn new(
4585 path: impl Into<String>,
4586 input: InputIdentity,
4587 rig: RigInfo,
4588 prediction_provenance: Option<PredictionProvenanceV1>,
4589 checks: Vec<CheckEvaluation>,
4590 measurements: MeasurementContract,
4591 ) -> Result<Self, OutputContractError> {
4592 let report = Self {
4593 evidence: FileEvidence::new(path, input, rig, measurements),
4594 prediction_provenance,
4595 checks,
4596 };
4597 report.validate()?;
4598 Ok(report)
4599 }
4600
4601 pub fn path(&self) -> &str {
4603 &self.evidence.path
4604 }
4605
4606 pub fn input(&self) -> &InputIdentity {
4608 &self.evidence.input
4609 }
4610
4611 pub fn checks(&self) -> &[CheckEvaluation] {
4613 &self.checks
4614 }
4615
4616 pub const fn prediction_provenance(&self) -> Option<&PredictionProvenanceV1> {
4618 self.prediction_provenance.as_ref()
4619 }
4620
4621 pub fn measurements(&self) -> &MeasurementContract {
4623 &self.evidence.measurements
4624 }
4625
4626 fn validate(&self) -> Result<(), OutputContractError> {
4627 if self.checks.len() > OUTPUT_V10_MAX_CHECKS_PER_FILE {
4628 return Err(OutputContractError::TooManyChecks {
4629 found: self.checks.len(),
4630 limit: OUTPUT_V10_MAX_CHECKS_PER_FILE,
4631 });
4632 }
4633 if let Some(provenance) = &self.prediction_provenance {
4634 provenance.validate()?;
4635 if provenance.raw_source().primary_input() != &self.evidence.input {
4636 return Err(OutputContractError::PredictionPrimaryInputMismatch);
4637 }
4638 }
4639
4640 let mut facets = 0usize;
4641 let mut references = 0usize;
4642 let mut text = self
4643 .prediction_provenance
4644 .as_ref()
4645 .map(PredictionProvenanceV1::retained_text_bytes)
4646 .transpose()?
4647 .unwrap_or(0);
4648 for check in &self.checks {
4649 let Some(prediction) = check.engine_prediction() else {
4650 continue;
4651 };
4652 let provenance = self
4653 .prediction_provenance
4654 .as_ref()
4655 .ok_or(OutputContractError::PredictionWithoutProvenance)?;
4656 prediction.validate_against_provenance(provenance)?;
4657 facets = facets
4658 .checked_add(prediction.facets().len())
4659 .ok_or(OutputContractError::ArithmeticOverflow)?;
4660 references = references
4661 .checked_add(prediction.basis_reference_count())
4662 .ok_or(OutputContractError::ArithmeticOverflow)?;
4663 text = text
4664 .checked_add(prediction.retained_text_bytes()?)
4665 .ok_or(OutputContractError::ArithmeticOverflow)?;
4666 }
4667 validate_measurement_references_batch(
4668 &self.evidence.measurements,
4669 self.checks
4670 .iter()
4671 .enumerate()
4672 .filter_map(|(check_index, check)| {
4673 check
4674 .engine_prediction()
4675 .map(|prediction| (check_index, prediction))
4676 }),
4677 )
4678 .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
4679 if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4680 return Err(OutputContractError::TooManyPredictionFacets {
4681 found: facets,
4682 limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4683 });
4684 }
4685 if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4686 return Err(OutputContractError::TooManyPredictionBasisReferences {
4687 found: references,
4688 limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4689 });
4690 }
4691 if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4692 return Err(OutputContractError::TooMuchPredictionText {
4693 found: text,
4694 limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4695 });
4696 }
4697 Ok(())
4698 }
4699}
4700
4701#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4703#[non_exhaustive]
4704pub enum OutputContractError {
4705 #[error("output contains {found} files, exceeding the v10 limit of {limit}")]
4707 TooManyFiles {
4708 found: usize,
4710 limit: usize,
4712 },
4713 #[error("lint file contains {found} checks, exceeding the v10 limit of {limit}")]
4715 TooManyChecks {
4716 found: usize,
4718 limit: usize,
4720 },
4721 #[error("engine prediction requires non-null file prediction_provenance")]
4723 PredictionWithoutProvenance,
4724 #[error("prediction provenance primary input does not match the lint file input")]
4726 PredictionPrimaryInputMismatch,
4727 #[error("lint file contains {found} prediction facets, exceeding the V1 limit of {limit}")]
4729 TooManyPredictionFacets {
4730 found: usize,
4732 limit: usize,
4734 },
4735 #[error(
4737 "lint file contains {found} prediction basis references, exceeding the V1 limit of {limit}"
4738 )]
4739 TooManyPredictionBasisReferences {
4740 found: usize,
4742 limit: usize,
4744 },
4745 #[error("lint file retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
4747 TooMuchPredictionText {
4748 found: usize,
4750 limit: usize,
4752 },
4753 #[error("checked arithmetic overflow while validating output-v10 bounds")]
4755 ArithmeticOverflow,
4756 #[error("invalid prediction evidence: {0}")]
4758 InvalidPrediction(#[from] PredictionContractError),
4759}
4760
4761#[derive(Debug, Clone, Serialize)]
4762struct EnvelopeHeader {
4763 schema_version: u32,
4764 schema: &'static str,
4765 tool: ToolInfo,
4766 command: &'static str,
4767}
4768
4769impl EnvelopeHeader {
4770 fn new(tool: ToolInfo, command: &'static str) -> Self {
4771 Self {
4772 schema_version: OUTPUT_SCHEMA_VERSION,
4773 schema: OUTPUT_SCHEMA_ID,
4774 tool,
4775 command,
4776 }
4777 }
4778}
4779
4780#[derive(Debug, Clone, Serialize)]
4781struct MeasureSummary {
4782 files: usize,
4783}
4784
4785#[derive(Debug, Clone, Default, Serialize)]
4786struct FindingSummary {
4787 error: usize,
4788 warning: usize,
4789 note: usize,
4790}
4791
4792impl FindingSummary {
4793 fn add(&mut self, severity: Severity) {
4794 match severity {
4795 Severity::Error => self.error += 1,
4796 Severity::Warning => self.warning += 1,
4797 Severity::Note => self.note += 1,
4798 }
4799 }
4800}
4801
4802#[derive(Debug, Clone, Default, Serialize)]
4803struct SelectionSummary {
4804 selected: usize,
4805 unselected: usize,
4806}
4807
4808#[derive(Debug, Clone, Default, Serialize)]
4809struct ConfigurationSummary {
4810 enabled: usize,
4811 disabled: usize,
4812}
4813
4814#[derive(Debug, Clone, Default, Serialize)]
4815struct ApplicabilitySummary {
4816 applicable: usize,
4817 not_applicable: usize,
4818}
4819
4820#[derive(Debug, Clone, Default, Serialize)]
4821struct EvaluationStateSummary {
4822 complete: usize,
4823 partial: usize,
4824 not_evaluated: usize,
4825}
4826
4827#[derive(Debug, Clone, Default, Serialize)]
4828struct CheckSummary {
4829 total: usize,
4830 selection: SelectionSummary,
4831 configuration: ConfigurationSummary,
4832 applicability: ApplicabilitySummary,
4833 evaluation: EvaluationStateSummary,
4834 gaps: usize,
4835}
4836
4837#[derive(Debug, Clone, Serialize)]
4838struct LintSummary {
4839 files: usize,
4840 findings: FindingSummary,
4841 checks: CheckSummary,
4842 prediction_facets: PredictionFacetSummary,
4843}
4844
4845#[derive(Debug, Clone, Default, Serialize)]
4846struct PredictionFacetSummary {
4847 available: usize,
4848 required_prediction_unavailable: usize,
4849}
4850
4851#[derive(Debug, Clone, Serialize)]
4853pub struct MeasureEnvelope {
4854 #[serde(flatten)]
4855 header: EnvelopeHeader,
4856 summary: MeasureSummary,
4857 files: Vec<MeasureFileReport>,
4858}
4859
4860impl MeasureEnvelope {
4861 pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Result<Self, OutputContractError> {
4863 if files.len() > OUTPUT_V10_MAX_FILES {
4864 return Err(OutputContractError::TooManyFiles {
4865 found: files.len(),
4866 limit: OUTPUT_V10_MAX_FILES,
4867 });
4868 }
4869 Ok(Self {
4870 header: EnvelopeHeader::new(tool, "measure"),
4871 summary: MeasureSummary { files: files.len() },
4872 files,
4873 })
4874 }
4875}
4876
4877#[derive(Debug, Clone, Serialize)]
4879pub struct LintEnvelope {
4880 #[serde(flatten)]
4881 header: EnvelopeHeader,
4882 summary: LintSummary,
4883 files: Vec<LintFileReport>,
4884}
4885
4886impl LintEnvelope {
4887 pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Result<Self, OutputContractError> {
4890 if files.len() > OUTPUT_V10_MAX_FILES {
4891 return Err(OutputContractError::TooManyFiles {
4892 found: files.len(),
4893 limit: OUTPUT_V10_MAX_FILES,
4894 });
4895 }
4896 let mut findings = FindingSummary::default();
4897 let mut checks = CheckSummary::default();
4898 let mut prediction_facets = PredictionFacetSummary::default();
4899 for file in &files {
4900 file.validate()?;
4901 for check in file.checks() {
4902 checks.total += 1;
4903 for finding in check.findings() {
4904 findings.add(finding.severity);
4905 }
4906 match check.selection() {
4907 SelectionState::Selected => checks.selection.selected += 1,
4908 SelectionState::Unselected => checks.selection.unselected += 1,
4909 }
4910 match check.configuration() {
4911 ConfigurationState::Enabled => checks.configuration.enabled += 1,
4912 ConfigurationState::Disabled => checks.configuration.disabled += 1,
4913 }
4914 match check.applicability() {
4915 Applicability::Applicable => checks.applicability.applicable += 1,
4916 Applicability::NotApplicable => checks.applicability.not_applicable += 1,
4917 }
4918 match check.evaluation() {
4919 EvaluationState::Complete => checks.evaluation.complete += 1,
4920 EvaluationState::Partial => checks.evaluation.partial += 1,
4921 EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
4922 }
4923 checks.gaps += check.gaps().len();
4924 if let Some(prediction) = check.engine_prediction() {
4925 for facet in prediction.facets() {
4926 match facet.state() {
4927 EnginePredictionFacetStateV1::Available => {
4928 prediction_facets.available += 1;
4929 }
4930 EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
4931 prediction_facets.required_prediction_unavailable += 1;
4932 }
4933 }
4934 }
4935 }
4936 }
4937 }
4938 Ok(Self {
4939 header: EnvelopeHeader::new(tool, "lint"),
4940 summary: LintSummary {
4941 files: files.len(),
4942 findings,
4943 checks,
4944 prediction_facets,
4945 },
4946 files,
4947 })
4948 }
4949}
4950
4951#[derive(Debug, Clone, Serialize)]
4952struct DiffInputs {
4953 before: String,
4954 after: String,
4955}
4956
4957#[derive(Debug, Clone, Serialize)]
4958struct DiffSummary {
4959 deltas: usize,
4960}
4961
4962#[derive(Debug, Serialize)]
4964pub struct DiffEnvelope {
4965 #[serde(flatten)]
4966 header: EnvelopeHeader,
4967 inputs: DiffInputs,
4968 summary: DiffSummary,
4969 deltas: Vec<MetricDelta>,
4970}
4971
4972impl DiffEnvelope {
4973 pub fn new(
4975 tool: ToolInfo,
4976 before: impl Into<String>,
4977 after: impl Into<String>,
4978 deltas: Vec<MetricDelta>,
4979 ) -> Self {
4980 Self {
4981 header: EnvelopeHeader::new(tool, "diff"),
4982 inputs: DiffInputs {
4983 before: before.into(),
4984 after: after.into(),
4985 },
4986 summary: DiffSummary {
4987 deltas: deltas.len(),
4988 },
4989 deltas,
4990 }
4991 }
4992}