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