animsmith_core/scale/proof.rs
1//! Private scale proof, residual, sampling, and exact-discharge implementation.
2//!
3//! This module independently derives every numeric expectation checked against
4//! a candidate. It deliberately does not import the reference writer's
5//! expected values, connector products, or factor association.
6
7use super::numeric::{
8 column_operand_magnitude, mat4_abs, matrix_magnitude, matrix_residual,
9 product_operand_magnitude, scale_translation_only,
10};
11use super::planning::{check_factor_narrows, validate_plan_document_inventory};
12use super::reference::ScaleCandidate;
13use super::validation::{
14 WorldBonePose, WorldPose, affected_skin_instance_indices, child_translation_rounding_magnitude,
15 instance_bind, local_rest_matrix, rest_world_pose, source_node_index_map,
16 validate_candidate_structure, validate_scale_input,
17};
18use super::{
19 ProofResidualKind, ScaleError, ScaleFieldDisposition, ScaleFieldTarget, ScaleOperation,
20 ScalePlan, ScaleProofObligation, ScaleRewriteRule, ScaleSourceNodeKind, ScaleSourceRestField,
21 ScaleTolerancePolicy,
22};
23use crate::model::{
24 BoneId, Clip, Document, DocumentShapeError, Interpolation, MeshInstanceShapeViolation,
25 Primitive, Property, Skeleton, SourceNodeAsset, SourceNodeLocalRest, TrackValues, Transform,
26 affine_axis_lengths, average_affine_axis_length, mat4_is_finite,
27};
28use crate::sample::{TrackSample, sample_track};
29use glam::{DMat3, DMat4, DVec3, Mat3, Mat4, Quat, Vec3, Vec4};
30use std::collections::{BTreeMap, BTreeSet};
31
32impl ScalePlan {
33 fn is_whole_document(&self) -> bool {
34 matches!(self.compiled, super::ScaleCompiledPlan::WholeDocument(_))
35 }
36
37 fn has_obligation(&self, expected: ScaleProofObligation) -> bool {
38 self.obligations().contains(&expected)
39 }
40
41 pub(super) fn rest_obligation(&self) -> Option<(&[BoneId], bool)> {
42 if self.has_obligation(ScaleProofObligation::RestWorldAndUnitScale) {
43 Some((self.affected_nodes(), true))
44 } else if self.has_obligation(ScaleProofObligation::RestWorld) {
45 Some((self.affected_nodes(), false))
46 } else {
47 None
48 }
49 }
50
51 fn transform_only_nodes(&self) -> Option<&[BoneId]> {
52 self.has_obligation(ScaleProofObligation::TransformOnlyAffine)
53 .then(|| self.transform_only_attachments())
54 }
55
56 fn has_key_translations(&self) -> bool {
57 self.has_obligation(ScaleProofObligation::KeyTranslations)
58 }
59
60 fn has_cubic_interiors(&self) -> bool {
61 self.has_obligation(ScaleProofObligation::CubicInteriors)
62 }
63
64 fn trajectory_nodes(&self) -> Option<&[BoneId]> {
65 self.has_obligation(ScaleProofObligation::Trajectories)
66 .then(|| self.affected_nodes())
67 }
68
69 fn has_skin_and_bounds(&self) -> bool {
70 self.has_obligation(ScaleProofObligation::SkinAndBounds)
71 }
72
73 fn has_unaffected_binds(&self) -> bool {
74 self.has_obligation(ScaleProofObligation::UnaffectedInverseBinds)
75 }
76}
77
78/// Independently compose one proof-side connector product.
79///
80/// This intentionally does not call the reference writer's connector-product
81/// cache: the raw source-projection check must not certify a writer bug by
82/// deriving its expectation through the writer's own product cache.
83fn proof_connector_product(
84 connector_tail: usize,
85 by_source_index: &BTreeMap<usize, &SourceNodeAsset>,
86 connector_product_by_tail: &mut BTreeMap<usize, DMat4>,
87) -> Result<DMat4, ScaleError> {
88 let mut suffix = Vec::new();
89 let mut visited = BTreeSet::new();
90 let mut cursor = connector_tail;
91 let mut product = loop {
92 if let Some(&cached) = connector_product_by_tail.get(&cursor) {
93 break cached;
94 }
95 if !visited.insert(cursor) {
96 return Err(ScaleError::IncompleteClosure {
97 reason: "cyclic_connector_source_parent_chain",
98 });
99 }
100 let node = by_source_index
101 .get(&cursor)
102 .ok_or(ScaleError::IncompleteClosure {
103 reason: "dangling_connector_source_node_index",
104 })?;
105 if node.bone.is_some() {
106 break DMat4::IDENTITY;
107 }
108 suffix.push(cursor);
109 cursor = node
110 .parent_source_node_index
111 .ok_or(ScaleError::IncompleteClosure {
112 reason: "connector_without_projected_ancestor",
113 })?;
114 };
115 while let Some(source) = suffix.pop() {
116 let node = by_source_index
117 .get(&source)
118 .ok_or(ScaleError::IncompleteClosure {
119 reason: "dangling_connector_source_node_index",
120 })?;
121 product *= local_rest_matrix(&node.local_rest).as_dmat4();
122 connector_product_by_tail.insert(source, product);
123 }
124 connector_product_by_tail
125 .get(&connector_tail)
126 .copied()
127 .ok_or(ScaleError::IncompleteClosure {
128 reason: "empty_connector_bridge",
129 })
130}
131
132/// Independently derive the exact f32 local that a bridged projected source
133/// row must carry in the candidate.
134fn proof_expected_bridged_source_local(
135 local_rest: &SourceNodeLocalRest,
136 connector: DMat4,
137 s_parent: f32,
138 s_node: f32,
139 bone: BoneId,
140) -> Result<SourceNodeLocalRest, ScaleError> {
141 if s_parent == 1.0 && s_node == 1.0 {
142 return Ok(local_rest.clone());
143 }
144 let inverse = DMat3::from_cols(
145 connector.x_axis.truncate(),
146 connector.y_axis.truncate(),
147 connector.z_axis.truncate(),
148 )
149 .inverse();
150 let offset = inverse * (connector.w_axis.truncate() * (f64::from(s_parent) - 1.0));
151 if !inverse.x_axis.is_finite()
152 || !inverse.y_axis.is_finite()
153 || !inverse.z_axis.is_finite()
154 || !offset.is_finite()
155 {
156 return Err(ScaleError::NonFiniteTransform { node: bone });
157 }
158 let expected = match local_rest {
159 SourceNodeLocalRest::Trs {
160 translation,
161 rotation,
162 scale,
163 } => SourceNodeLocalRest::Trs {
164 translation: (translation.as_dvec3() * f64::from(s_parent) + offset).as_vec3(),
165 rotation: *rotation,
166 scale: (scale.as_dvec3() * (f64::from(s_parent) / f64::from(s_node))).as_vec3(),
167 },
168 SourceNodeLocalRest::Matrix(matrix) => {
169 let ratio = f64::from(s_parent) / f64::from(s_node);
170 let rebase_linear_column = |column: Vec4| {
171 (column.truncate().as_dvec3() * ratio)
172 .as_vec3()
173 .extend(column.w)
174 };
175 let translation =
176 (matrix.w_axis.truncate().as_dvec3() * f64::from(s_parent) + offset).as_vec3();
177 SourceNodeLocalRest::Matrix(Mat4::from_cols(
178 rebase_linear_column(matrix.x_axis),
179 rebase_linear_column(matrix.y_axis),
180 rebase_linear_column(matrix.z_axis),
181 translation.extend(matrix.w_axis.w),
182 ))
183 }
184 };
185 if !mat4_is_finite(local_rest_matrix(&expected)) {
186 return Err(ScaleError::NonFiniteTransform { node: bone });
187 }
188 Ok(expected)
189}
190
191fn vec3_bits_equal(left: Vec3, right: Vec3) -> bool {
192 left.to_array().map(f32::to_bits) == right.to_array().map(f32::to_bits)
193}
194
195fn quat_bits_equal(left: Quat, right: Quat) -> bool {
196 left.to_array().map(f32::to_bits) == right.to_array().map(f32::to_bits)
197}
198
199fn source_rest_field_bits_equal(
200 left: &SourceNodeLocalRest,
201 right: &SourceNodeLocalRest,
202 field: ScaleSourceRestField,
203) -> bool {
204 match (left, right, field) {
205 (
206 SourceNodeLocalRest::Trs {
207 translation: left, ..
208 },
209 SourceNodeLocalRest::Trs {
210 translation: right, ..
211 },
212 ScaleSourceRestField::Translation,
213 )
214 | (
215 SourceNodeLocalRest::Trs { scale: left, .. },
216 SourceNodeLocalRest::Trs { scale: right, .. },
217 ScaleSourceRestField::Scale,
218 ) => vec3_bits_equal(*left, *right),
219 (
220 SourceNodeLocalRest::Trs { rotation: left, .. },
221 SourceNodeLocalRest::Trs {
222 rotation: right, ..
223 },
224 ScaleSourceRestField::Rotation,
225 ) => quat_bits_equal(*left, *right),
226 (
227 SourceNodeLocalRest::Matrix(left),
228 SourceNodeLocalRest::Matrix(right),
229 ScaleSourceRestField::MatrixLinear,
230 ) => {
231 vec3_bits_equal(left.x_axis.truncate(), right.x_axis.truncate())
232 && vec3_bits_equal(left.y_axis.truncate(), right.y_axis.truncate())
233 && vec3_bits_equal(left.z_axis.truncate(), right.z_axis.truncate())
234 }
235 (
236 SourceNodeLocalRest::Matrix(left),
237 SourceNodeLocalRest::Matrix(right),
238 ScaleSourceRestField::MatrixTranslation,
239 ) => vec3_bits_equal(left.w_axis.truncate(), right.w_axis.truncate()),
240 (
241 SourceNodeLocalRest::Matrix(left),
242 SourceNodeLocalRest::Matrix(right),
243 ScaleSourceRestField::MatrixHomogeneous,
244 ) => {
245 [left.x_axis.w, left.y_axis.w, left.z_axis.w, left.w_axis.w].map(f32::to_bits)
246 == [
247 right.x_axis.w,
248 right.y_axis.w,
249 right.z_axis.w,
250 right.w_axis.w,
251 ]
252 .map(f32::to_bits)
253 }
254 _ => false,
255 }
256}
257
258fn f32_values_within_scale_tolerance<const N: usize>(
259 left: [f32; N],
260 right: [f32; N],
261 tolerance: &ScaleTolerancePolicy,
262) -> bool {
263 left.into_iter().zip(right).all(|(left, right)| {
264 let left = f64::from(left);
265 let right = f64::from(right);
266 let residual = (left - right).abs();
267 let limit = tolerance.scalar_tolerance(left, right);
268 residual.is_finite() && limit.is_finite() && residual <= limit
269 })
270}
271
272fn rewritten_source_rest_field_within_tolerance(
273 expected: &SourceNodeLocalRest,
274 actual: &SourceNodeLocalRest,
275 field: ScaleSourceRestField,
276 tolerance: &ScaleTolerancePolicy,
277) -> bool {
278 match (expected, actual, field) {
279 (
280 SourceNodeLocalRest::Trs {
281 translation: expected,
282 ..
283 },
284 SourceNodeLocalRest::Trs {
285 translation: actual,
286 ..
287 },
288 ScaleSourceRestField::Translation,
289 )
290 | (
291 SourceNodeLocalRest::Trs {
292 scale: expected, ..
293 },
294 SourceNodeLocalRest::Trs { scale: actual, .. },
295 ScaleSourceRestField::Scale,
296 ) => f32_values_within_scale_tolerance(expected.to_array(), actual.to_array(), tolerance),
297 (
298 SourceNodeLocalRest::Matrix(expected),
299 SourceNodeLocalRest::Matrix(actual),
300 ScaleSourceRestField::MatrixLinear,
301 ) => f32_values_within_scale_tolerance(
302 [
303 expected.x_axis.x,
304 expected.x_axis.y,
305 expected.x_axis.z,
306 expected.y_axis.x,
307 expected.y_axis.y,
308 expected.y_axis.z,
309 expected.z_axis.x,
310 expected.z_axis.y,
311 expected.z_axis.z,
312 ],
313 [
314 actual.x_axis.x,
315 actual.x_axis.y,
316 actual.x_axis.z,
317 actual.y_axis.x,
318 actual.y_axis.y,
319 actual.y_axis.z,
320 actual.z_axis.x,
321 actual.z_axis.y,
322 actual.z_axis.z,
323 ],
324 tolerance,
325 ),
326 (
327 SourceNodeLocalRest::Matrix(expected),
328 SourceNodeLocalRest::Matrix(actual),
329 ScaleSourceRestField::MatrixTranslation,
330 ) => f32_values_within_scale_tolerance(
331 expected.w_axis.truncate().to_array(),
332 actual.w_axis.truncate().to_array(),
333 tolerance,
334 ),
335 // Rewrites never own rotation or the matrix homogeneous row. Keep
336 // these impossible combinations fail-closed instead of silently
337 // assigning them a numeric policy.
338 _ => false,
339 }
340}
341
342/// Independently derive the raw authored local expected by one rewrite row.
343///
344/// This is deliberately proof-owned arithmetic. In particular, direct rows
345/// spell out their `f32` association here instead of calling the builder's
346/// rebase helpers, while connector rows use the separately implemented
347/// proof-side connector product and widened affine derivation.
348fn proof_expected_rewritten_source_local(
349 source: &Document,
350 plan: &ScalePlan,
351 affected: &BTreeSet<BoneId>,
352 source_node: &SourceNodeAsset,
353 rule: ScaleRewriteRule,
354 connector_products: &mut BTreeMap<usize, DMat4>,
355 source_nodes: &BTreeMap<usize, &SourceNodeAsset>,
356) -> Result<SourceNodeLocalRest, ScaleError> {
357 match rule {
358 ScaleRewriteRule::WholeDocumentLength => {
359 let q = check_factor_narrows(plan.common_factor(), plan.common_factor())?;
360 Ok(match &source_node.local_rest {
361 SourceNodeLocalRest::Trs {
362 translation,
363 rotation,
364 scale,
365 } => SourceNodeLocalRest::Trs {
366 translation: Vec3::new(translation.x * q, translation.y * q, translation.z * q),
367 rotation: *rotation,
368 scale: *scale,
369 },
370 SourceNodeLocalRest::Matrix(matrix) => {
371 SourceNodeLocalRest::Matrix(Mat4::from_cols(
372 matrix.x_axis,
373 matrix.y_axis,
374 matrix.z_axis,
375 Vec4::new(
376 matrix.w_axis.x * q,
377 matrix.w_axis.y * q,
378 matrix.w_axis.z * q,
379 matrix.w_axis.w,
380 ),
381 ))
382 }
383 })
384 }
385 ScaleRewriteRule::RestBindSourceLocal { connector_tail } => {
386 let bone = source_node
387 .bone
388 .ok_or(ScaleError::SourceNodeNotNormalized {
389 source_node_index: source_node.source_node_index,
390 })?;
391 let parent = source
392 .skeleton
393 .bones
394 .get(bone)
395 .ok_or(ScaleError::BoneIndexOutOfRange { index: bone })?
396 .parent;
397 let s = check_factor_narrows(plan.common_factor(), plan.common_factor())?;
398 let s_parent = if parent.is_some_and(|parent| affected.contains(&parent)) {
399 s
400 } else {
401 1.0
402 };
403 let s_node = if affected.contains(&bone) { s } else { 1.0 };
404 if let Some(connector_tail) = connector_tail {
405 let connector =
406 proof_connector_product(connector_tail, source_nodes, connector_products)?;
407 return proof_expected_bridged_source_local(
408 &source_node.local_rest,
409 connector,
410 s_parent,
411 s_node,
412 bone,
413 );
414 }
415 Ok(match &source_node.local_rest {
416 SourceNodeLocalRest::Trs {
417 translation,
418 rotation,
419 scale,
420 } => SourceNodeLocalRest::Trs {
421 translation: Vec3::new(
422 translation.x * s_parent,
423 translation.y * s_parent,
424 translation.z * s_parent,
425 ),
426 rotation: *rotation,
427 scale: Vec3::new(
428 scale.x * (s_parent / s_node),
429 scale.y * (s_parent / s_node),
430 scale.z * (s_parent / s_node),
431 ),
432 },
433 SourceNodeLocalRest::Matrix(matrix) => {
434 let inverse_node = 1.0 / s_node;
435 let linear = |column: Vec4| {
436 Vec4::new(
437 column.x * s_parent * inverse_node,
438 column.y * s_parent * inverse_node,
439 column.z * s_parent * inverse_node,
440 column.w * inverse_node,
441 )
442 };
443 SourceNodeLocalRest::Matrix(Mat4::from_cols(
444 linear(matrix.x_axis),
445 linear(matrix.y_axis),
446 linear(matrix.z_axis),
447 Vec4::new(
448 matrix.w_axis.x * s_parent,
449 matrix.w_axis.y * s_parent,
450 matrix.w_axis.z * s_parent,
451 matrix.w_axis.w,
452 ),
453 ))
454 }
455 })
456 }
457 ScaleRewriteRule::RestBindParentBasis
458 | ScaleRewriteRule::RestBindLocalScale
459 | ScaleRewriteRule::RestBindNodeBasis => Err(ScaleError::PlanDocumentMismatch {
460 reason: "invalid_source_local_rewrite_rule",
461 }),
462 }
463}
464
465/// Discharge rewritten authored source-local rows against a proof-owned
466/// analytic expectation. Direct raw format adapters may narrow an authored
467/// value before or after applying the same factor, so those rows use the
468/// published scale tolerance. Connector-bridged successors remain bit-exact:
469/// that core-only path has no second frontend narrowing boundary.
470fn check_rewritten_source_field_dispositions(
471 source: &Document,
472 candidate: &Document,
473 plan: &ScalePlan,
474 affected: &BTreeSet<BoneId>,
475 tolerance: &ScaleTolerancePolicy,
476 discharged: &mut BTreeSet<usize>,
477) -> Result<(), ScaleError> {
478 let source_nodes = source_node_index_map(source);
479 let candidate_nodes = source_node_index_map(candidate);
480 let mut connector_products = BTreeMap::new();
481 for (row_index, row) in plan.field_rows().iter().enumerate() {
482 let (
483 ScaleFieldTarget::SourceNodeRest {
484 source_node_index,
485 field,
486 },
487 ScaleFieldDisposition::Rewrite(rule),
488 ) = (row.target, row.disposition)
489 else {
490 continue;
491 };
492 let before =
493 source_nodes
494 .get(&source_node_index)
495 .ok_or(ScaleError::CandidateStructureMismatch {
496 reason: "rewritten_source_node_missing",
497 })?;
498 let after = candidate_nodes.get(&source_node_index).ok_or(
499 ScaleError::CandidateStructureMismatch {
500 reason: "rewritten_source_node_missing",
501 },
502 )?;
503 let expected = proof_expected_rewritten_source_local(
504 source,
505 plan,
506 affected,
507 before,
508 rule,
509 &mut connector_products,
510 &source_nodes,
511 )?;
512 let bridged = matches!(
513 rule,
514 ScaleRewriteRule::RestBindSourceLocal {
515 connector_tail: Some(_)
516 }
517 );
518 let matches = if bridged {
519 source_rest_field_bits_equal(&expected, &after.local_rest, field)
520 } else {
521 rewritten_source_rest_field_within_tolerance(
522 &expected,
523 &after.local_rest,
524 field,
525 tolerance,
526 )
527 };
528 if !matches {
529 return Err(ScaleError::CandidateStructureMismatch {
530 reason: if bridged {
531 "bridged_source_local_mismatch"
532 } else {
533 "field_disposition_mismatch"
534 },
535 });
536 }
537 mark_field_row_discharged(discharged, row_index)?;
538 }
539 Ok(())
540}
541
542/// Discharge preserve-exact authored source-local rows after semantic
543/// residuals succeed. These are raw fields both core builders copy directly;
544/// normalized fields may be independently re-derived by a format frontend
545/// and remain governed by the existing versioned residual policy.
546fn check_preserved_field_dispositions(
547 source: &Document,
548 candidate: &Document,
549 plan: &ScalePlan,
550 discharged: &mut BTreeSet<usize>,
551) -> Result<(), ScaleError> {
552 let source_nodes = source_node_index_map(source);
553 let candidate_nodes = source_node_index_map(candidate);
554 let mut connector_sources = BTreeSet::new();
555 let mut bridged_successors = BTreeSet::new();
556 for row in plan.ledger().source_topology() {
557 match row.kind() {
558 ScaleSourceNodeKind::Connector => {
559 connector_sources.insert(row.source_node_index());
560 }
561 ScaleSourceNodeKind::Projected {
562 incoming_connector_tail: Some(_),
563 ..
564 } => {
565 bridged_successors.insert(row.source_node_index());
566 }
567 ScaleSourceNodeKind::Projected { .. } | ScaleSourceNodeKind::OutsideDomain { .. } => {}
568 }
569 }
570 for (row_index, row) in plan.field_rows().iter().enumerate() {
571 let (
572 ScaleFieldTarget::SourceNodeRest {
573 source_node_index,
574 field,
575 },
576 ScaleFieldDisposition::PreserveExact,
577 ) = (row.target, row.disposition)
578 else {
579 continue;
580 };
581 let exact = match (
582 source_nodes.get(&source_node_index),
583 candidate_nodes.get(&source_node_index),
584 ) {
585 (Some(before), Some(after)) => {
586 source_rest_field_bits_equal(&before.local_rest, &after.local_rest, field)
587 }
588 // Unavailable coverage carries no authoritative raw-row identity;
589 // the compiler emits no source field rows for it.
590 (None, None) => true,
591 _ => false,
592 };
593 if !exact {
594 return Err(ScaleError::CandidateStructureMismatch {
595 reason: if connector_sources.contains(&source_node_index) {
596 "connector_source_local_mismatch"
597 } else if bridged_successors.contains(&source_node_index) {
598 "bridged_source_local_mismatch"
599 } else {
600 "field_disposition_mismatch"
601 },
602 });
603 }
604 mark_field_row_discharged(discharged, row_index)?;
605 }
606 Ok(())
607}
608
609fn mark_field_row_discharged(
610 discharged: &mut BTreeSet<usize>,
611 row_index: usize,
612) -> Result<(), ScaleError> {
613 if !discharged.insert(row_index) {
614 return Err(ScaleError::PlanDocumentMismatch {
615 reason: "field_row_discharged_twice",
616 });
617 }
618 Ok(())
619}
620
621fn finish_field_row_discharge(
622 plan: &ScalePlan,
623 discharged: &BTreeSet<usize>,
624) -> Result<(), ScaleError> {
625 let expected: BTreeSet<_> = (0..plan.field_rows().len()).collect();
626 if *discharged != expected {
627 return Err(ScaleError::PlanDocumentMismatch {
628 reason: "field_row_not_discharged",
629 });
630 }
631 Ok(())
632}
633
634// --- Proof -------------------------------------------------------------
635
636mod residual {
637 /// One proof claim's maximum residual and the comparisons behind it.
638 ///
639 /// A maximum of `0.0` can mean either an exact measurement or that no
640 /// comparison was made. [`Self::evaluated`] distinguishes those cases.
641 /// The two measurements are intentionally one read-only value so a
642 /// consumer cannot pair one claim's maximum with another claim's count.
643 #[derive(Debug, Clone, Copy, PartialEq)]
644 #[non_exhaustive]
645 pub struct ScaleProofResidual {
646 max: f64,
647 comparisons: usize,
648 }
649
650 impl ScaleProofResidual {
651 /// The maximum residual observed across this claim's comparisons.
652 #[must_use]
653 pub fn max(self) -> f64 {
654 self.max
655 }
656
657 /// The number of comparisons behind [`Self::max`].
658 #[must_use]
659 pub fn comparisons(self) -> usize {
660 self.comparisons
661 }
662
663 /// Whether the proof evaluated this claim at least once.
664 #[must_use]
665 pub fn evaluated(self) -> bool {
666 self.comparisons != 0
667 }
668
669 pub(super) const EMPTY: Self = Self {
670 max: 0.0,
671 comparisons: 0,
672 };
673
674 pub(super) fn record(&mut self, observed: f64) {
675 self.max = self.max.max(observed);
676 self.comparisons += 1;
677 }
678 }
679
680 #[cfg(doctest)]
681 mod api_contract {
682 /// Compile-fail coverage for the removed split API. Each field stays in
683 /// its own compilation unit so restoring one cannot be masked by a
684 /// different missing field.
685 ///
686 /// ```compile_fail
687 /// use animsmith_core::ScaleProof;
688 ///
689 /// fn removed(proof: ScaleProof) {
690 /// let _ = proof.rest_translation_residual;
691 /// }
692 /// ```
693 ///
694 /// ```compile_fail
695 /// use animsmith_core::ScaleProof;
696 ///
697 /// fn removed(proof: ScaleProof) {
698 /// let _ = proof.rest_translation_comparisons;
699 /// }
700 /// ```
701 ///
702 /// ```compile_fail
703 /// use animsmith_core::ScaleProof;
704 ///
705 /// fn removed(proof: ScaleProof) {
706 /// let _ = proof.rest_rotation_residual;
707 /// }
708 /// ```
709 ///
710 /// ```compile_fail
711 /// use animsmith_core::ScaleProof;
712 ///
713 /// fn removed(proof: ScaleProof) {
714 /// let _ = proof.rest_rotation_comparisons;
715 /// }
716 /// ```
717 ///
718 /// ```compile_fail
719 /// use animsmith_core::ScaleProof;
720 ///
721 /// fn removed(proof: ScaleProof) {
722 /// let _ = proof.unit_scale_residual;
723 /// }
724 /// ```
725 ///
726 /// ```compile_fail
727 /// use animsmith_core::ScaleProof;
728 ///
729 /// fn removed(proof: ScaleProof) {
730 /// let _ = proof.unit_scale_comparisons;
731 /// }
732 /// ```
733 ///
734 /// ```compile_fail
735 /// use animsmith_core::ScaleProof;
736 ///
737 /// fn removed(proof: ScaleProof) {
738 /// let _ = proof.transform_only_affine_residual;
739 /// }
740 /// ```
741 ///
742 /// ```compile_fail
743 /// use animsmith_core::ScaleProof;
744 ///
745 /// fn removed(proof: ScaleProof) {
746 /// let _ = proof.transform_only_affine_comparisons;
747 /// }
748 /// ```
749 ///
750 /// ```compile_fail
751 /// use animsmith_core::ScaleProof;
752 ///
753 /// fn removed(proof: ScaleProof) {
754 /// let _ = proof.track_value_residual;
755 /// }
756 /// ```
757 ///
758 /// ```compile_fail
759 /// use animsmith_core::ScaleProof;
760 ///
761 /// fn removed(proof: ScaleProof) {
762 /// let _ = proof.track_value_comparisons;
763 /// }
764 /// ```
765 ///
766 /// ```compile_fail
767 /// use animsmith_core::ScaleProof;
768 ///
769 /// fn removed(proof: ScaleProof) {
770 /// let _ = proof.mesh_position_residual;
771 /// }
772 /// ```
773 ///
774 /// ```compile_fail
775 /// use animsmith_core::ScaleProof;
776 ///
777 /// fn removed(proof: ScaleProof) {
778 /// let _ = proof.mesh_position_comparisons;
779 /// }
780 /// ```
781 ///
782 /// ```compile_fail
783 /// use animsmith_core::ScaleProof;
784 ///
785 /// fn removed(proof: ScaleProof) {
786 /// let _ = proof.key_translation_residual;
787 /// }
788 /// ```
789 ///
790 /// ```compile_fail
791 /// use animsmith_core::ScaleProof;
792 ///
793 /// fn removed(proof: ScaleProof) {
794 /// let _ = proof.key_translation_comparisons;
795 /// }
796 /// ```
797 ///
798 /// ```compile_fail
799 /// use animsmith_core::ScaleProof;
800 ///
801 /// fn removed(proof: ScaleProof) {
802 /// let _ = proof.cubic_interior_residual;
803 /// }
804 /// ```
805 ///
806 /// ```compile_fail
807 /// use animsmith_core::ScaleProof;
808 ///
809 /// fn removed(proof: ScaleProof) {
810 /// let _ = proof.cubic_interior_comparisons;
811 /// }
812 /// ```
813 ///
814 /// ```compile_fail
815 /// use animsmith_core::ScaleProof;
816 ///
817 /// fn removed(proof: ScaleProof) {
818 /// let _ = proof.trajectory_residual;
819 /// }
820 /// ```
821 ///
822 /// ```compile_fail
823 /// use animsmith_core::ScaleProof;
824 ///
825 /// fn removed(proof: ScaleProof) {
826 /// let _ = proof.trajectory_comparisons;
827 /// }
828 /// ```
829 ///
830 /// ```compile_fail
831 /// use animsmith_core::ScaleProof;
832 ///
833 /// fn removed(proof: ScaleProof) {
834 /// let _ = proof.skin_matrix_residual;
835 /// }
836 /// ```
837 ///
838 /// ```compile_fail
839 /// use animsmith_core::ScaleProof;
840 ///
841 /// fn removed(proof: ScaleProof) {
842 /// let _ = proof.skin_matrix_comparisons;
843 /// }
844 /// ```
845 ///
846 /// ```compile_fail
847 /// use animsmith_core::ScaleProof;
848 ///
849 /// fn removed(proof: ScaleProof) {
850 /// let _ = proof.bounds_residual;
851 /// }
852 /// ```
853 ///
854 /// ```compile_fail
855 /// use animsmith_core::ScaleProof;
856 ///
857 /// fn removed(proof: ScaleProof) {
858 /// let _ = proof.bounds_comparisons;
859 /// }
860 /// ```
861 ///
862 /// ```compile_fail
863 /// use animsmith_core::ScaleProof;
864 ///
865 /// fn removed(proof: ScaleProof) {
866 /// let _ = proof.unaffected_inverse_bind_residual;
867 /// }
868 /// ```
869 ///
870 /// ```compile_fail
871 /// use animsmith_core::ScaleProof;
872 ///
873 /// fn removed(proof: ScaleProof) {
874 /// let _ = proof.unaffected_inverse_bind_comparisons;
875 /// }
876 /// ```
877 ///
878 /// Direct construction and member-by-member mutation are also unavailable:
879 ///
880 /// ```compile_fail
881 /// use animsmith_core::ScaleProofResidual;
882 ///
883 /// let _ = ScaleProofResidual {
884 /// max: 0.0,
885 /// comparisons: 1,
886 /// };
887 /// ```
888 ///
889 /// ```compile_fail
890 /// use animsmith_core::ScaleProofResidual;
891 ///
892 /// fn replace_max(mut residual: ScaleProofResidual) {
893 /// residual.max = 1.0;
894 /// }
895 /// ```
896 ///
897 /// ```compile_fail
898 /// use animsmith_core::ScaleProofResidual;
899 ///
900 /// fn replace_count(mut residual: ScaleProofResidual) {
901 /// residual.comparisons = 1;
902 /// }
903 /// ```
904
905 struct RemovedSplitFields;
906 }
907}
908
909pub use residual::ScaleProofResidual;
910
911/// Observed residual maxima from [`prove_scale`], reported against
912/// [`ScalePlan::tolerance_policy`], each paired with the number of
913/// comparisons that produced it.
914///
915/// **Read the paired value, not just its maximum.** A maximum alone cannot
916/// distinguish "compared, no deviation" from "nothing to compare": both read
917/// `0.0`, because every maximum starts there and is raised only by a loop
918/// that may have zero iterations. So a field for an obligation the plan does
919/// not require (see [`ScaleProofObligation`]) reports a
920/// [`ScaleProofResidual`] whose maximum and count are both zero. A `0.0`
921/// maximum with a count above zero is a measurement; a zero count is an
922/// absence, which DESIGN.md Appendix D §D.6 requires an evidence record to
923/// publish as an absence rather than as a checked zero.
924///
925/// The counts are measurements, not proxies derived from obligation presence:
926/// each is stored with its maximum by the single private recording method
927/// every comparison in [`prove_scale`] funnels through. The pair's fields are
928/// private, so no producer can combine one claim's count with another claim's
929/// maximum.
930/// No comparison can raise a residual without being counted, and
931/// no count can rise without a comparison having been checked against the
932/// tolerance policy. A [`ScaleProofObligation`] may declare a proof walk, or
933/// name a row-driven claim discharged through the canonical field inventory;
934/// neither role substitutes for the comparison count recorded here.
935#[derive(Debug, Clone, Copy, PartialEq)]
936#[non_exhaustive]
937pub struct ScaleProof {
938 /// The tolerance policy every residual below was checked against.
939 pub tolerance_policy: ScaleTolerancePolicy,
940 /// Maximum rest-world translation residual, with one comparison per
941 /// affected node.
942 pub rest_translation: ScaleProofResidual,
943 /// Maximum rest-world rotation residual, in radians, directly comparable
944 /// to [`ScaleTolerancePolicy::rotation_residual_radians`].
945 ///
946 /// Measured as a double-cover-aware quaternion chord length
947 /// `|q1 - q2| = 2 * sin(theta / 4)` and reported as the angle
948 /// `theta = 4 * asin(chord / 2)` that chord represents, so this value and
949 /// the tolerance it is checked against carry the same unit.
950 /// Its count is one per affected node.
951 pub rest_rotation: ScaleProofResidual,
952 /// Maximum postcondition unit-scale residual, with one comparison per
953 /// affected node when the rest/bind plan declares the postcondition.
954 pub unit_scale: ScaleProofResidual,
955 /// Maximum transform-only attachment full-affine residual (rest/bind
956 /// only), with one probe-point comparison per attachment.
957 pub transform_only_affine: ScaleProofResidual,
958 /// Maximum per-element animation-track value residual, across rewritten
959 /// translation elements and every retained rotation/scale element. Its
960 /// count is one per track element across every clip.
961 pub track_value: ScaleProofResidual,
962 /// Maximum per-vertex base mesh `POSITION` residual, with one comparison
963 /// per vertex.
964 pub mesh_position: ScaleProofResidual,
965 /// Maximum residual at any affected-track keyframe time, with one
966 /// comparison per affected translation track per key time.
967 pub key_translation: ScaleProofResidual,
968 /// Maximum residual at any bounded cubic-segment interior time, with one
969 /// comparison per affected translation track per interior time.
970 pub cubic_interior: ScaleProofResidual,
971 /// Maximum sampled world-space trajectory residual, with one comparison
972 /// per affected node per sample time.
973 pub trajectory: ScaleProofResidual,
974 /// Maximum skin-matrix (`W * B`) component residual, across rest and
975 /// every sampled key/cubic-interior time. Its count is one per skin slot
976 /// of every affected skinned instance at each evaluated pose.
977 pub skin_matrix: ScaleProofResidual,
978 /// Maximum skinned mesh bounds residual, across rest and every sampled
979 /// key/cubic-interior time. Its count is six per evaluated pose.
980 pub bounds: ScaleProofResidual,
981 /// Maximum effective inverse-bind residual over every skin slot outside
982 /// the affected closure (see [`ProofResidualKind::UnaffectedInverseBind`]).
983 /// Its count is one per resolved slot compared outside the closure and
984 /// zero unless the plan declares
985 /// [`ScaleProofObligation::UnaffectedInverseBinds`].
986 pub unaffected_inverse_bind: ScaleProofResidual,
987 /// The operation's observed factor, re-derived by this proof from the
988 /// documents it was handed rather than copied from
989 /// [`ScalePlan::observed_factor`], so evidence does not depend on
990 /// planning having recorded it.
991 ///
992 /// **Reported, not checked.** This is a measurement, not an obligation:
993 /// nothing here compares it against [`ScalePlan::common_factor`]. The
994 /// declared/observed agreement is the *input* contract §D.1 states, and
995 /// planning already enforces it ([`ScaleError::FactorMismatch`]); what
996 /// binds the candidate is the postcondition
997 /// ([`ProofResidualKind::UnitScale`]) that §D.1 derives from that band.
998 ///
999 /// Measured from `source`, not from `candidate`. That is a choice, not an
1000 /// impossibility: a rest/bind candidate does record what its source
1001 /// measured. `build_rest_bind` rebases an affected node's local scale by
1002 /// `s_parent / s_node` with `s_node` the *declared* factor, and the
1003 /// affected closure never contains the scaled root's parent, so `s_parent`
1004 /// is one there and the candidate's composed root scale is exactly
1005 /// `s_observed / s_declared` — unit only when the two agree, which the
1006 /// input band admits without requiring. The candidate route is real and it
1007 /// is accurate: `plan.common_factor() * average_affine_axis_length(
1008 /// affine_axis_lengths(candidate root world linear))` recovers the
1009 /// measurement to a relative error below `2^-24`, the half-ulp of the one
1010 /// binary32 rounding that round trip costs at unit magnitude. Swept over
1011 /// all 215 binary32 values
1012 /// the `1e-5` band admits around a declared `0.01`, the worst is
1013 /// `5.9604613e-8`; over this module's own `0.01`-factor fixtures it is
1014 /// `4.84e-8`, at a source root of `0.010_000_099`.
1015 ///
1016 /// It is not the route taken, for four reasons:
1017 ///
1018 /// - **Independence.** [`prove_scale`] does not require `candidate` to
1019 /// have come from reference construction; checking one that did not
1020 /// is the reason it exists. Reading the reported measurement off the
1021 /// artifact under test would let that artifact pick its own evidence
1022 /// value. The observed factor is a fact about the *input*, so the input
1023 /// is where it is measured.
1024 /// - **Precision.** The candidate route divides by the declared factor in
1025 /// `f32` and multiplies back in `f64`, so it reports a rounded
1026 /// neighbour of the source measurement rather than the measurement.
1027 /// - **Sign.** The nearest already-published proxy,
1028 /// [`Self::unit_scale`]'s `max()`, is an absolute value, so
1029 /// `common_factor * (1 + unit_scale.max())` reconstructs
1030 /// `s_observed` only when the observed factor is the *larger* of the
1031 /// two and reflects it about the declared factor when it is not.
1032 /// - **Attribution.** That residual is also a maximum over every affected
1033 /// node rather than a value read at the scaled root, so it does not
1034 /// name the node §D.6 defines the observed factor at.
1035 ///
1036 /// See [`ScalePlan::observed_factor`] for how each operation defines it;
1037 /// for a whole-document conversion this is the declared factor, there
1038 /// being nothing to measure — and there the candidate route genuinely
1039 /// does not exist, because `build_whole_document` rewrites translations
1040 /// only and leaves every composed scale exactly as it found it.
1041 pub observed_factor: f64,
1042 /// The observed factor [`super::plan_scale`] measured, copied from
1043 /// [`ScalePlan::observed_factor`].
1044 ///
1045 /// The record carries both witnesses because they are measured from
1046 /// genuinely different state and neither is derivable from the other:
1047 /// this one from the raw source projection (`SourceNodeAsset::local_rest`
1048 /// composed through `parent_source_node_index`),
1049 /// [`Self::observed_factor`] from the normalized skeleton (`Bone::rest`
1050 /// composed through `world_rest_matrices`). That independence is the
1051 /// property DESIGN.md Appendix D §D.6 wants of a second witness, and it
1052 /// is why the two are generally not equal. Carrying only one of them
1053 /// would leave a reader unable to tell which was reported; carrying both
1054 /// with no stated relationship would leave them unable to tell which to
1055 /// trust, which is what [`Self::observed_factor_divergence`] answers.
1056 ///
1057 /// For [`ScaleOperation::WholeDocumentLinearUnits`] both are the declared
1058 /// factor, there being nothing to measure, and the divergence is exactly
1059 /// zero.
1060 pub planned_observed_factor: f64,
1061 /// How far apart the two observed factors are:
1062 /// `abs(planned - proved) / max(abs(planned), abs(proved))`.
1063 ///
1064 /// Recorded explicitly rather than left for a consumer to compute, so the
1065 /// evidence record states the relationship between its own two witnesses
1066 /// instead of presenting two numbers that both answer to "the observed
1067 /// factor". Compare it against
1068 /// [`ScaleTolerancePolicy::observed_factor_divergence_ceiling`], which is
1069 /// how far apart the design expects them to be and why — a consumer does
1070 /// not have to re-derive that ceiling by summing two separate policy
1071 /// fields.
1072 ///
1073 /// **Reported, not checked.** Nothing refuses a document for exceeding
1074 /// the ceiling; see that method for what the ceiling does and does not
1075 /// guarantee. The two *chains* the witnesses compose through are already
1076 /// required to agree: under
1077 /// [`crate::model::SourceSkeletonCoverage::Complete`] coverage a document
1078 /// whose projection and skeleton describe different trees is refused
1079 /// before either witness is taken. What nothing reconciles is the two
1080 /// *readings* — [`crate::model::SourceNodeAsset::local_rest`] and
1081 /// [`crate::model::Bone::rest`] stay separately stored and separately
1082 /// composed, which is why both witnesses exist at all. A divergence
1083 /// beyond the ceiling is therefore a fact about how far apart the input's
1084 /// two stored descriptions of one rest pose are, worth surfacing, not a
1085 /// residual this proof owns.
1086 pub observed_factor_divergence: f64,
1087 /// Number of distinct times sampled across all clips.
1088 pub sample_time_count: usize,
1089 // Calibration-only raw f32-rounding demand maxima. These are intentionally
1090 // private and test-only: evidence publishes residuals and comparison
1091 // counts, while the ignored calibration needs the per-comparison
1092 // `observed / (base * ulp)` maximum that the proof actually checked.
1093 // Keeping it on the test build's proof makes calibration consume the
1094 // production comparison instead of recomputing poses, slots, bounds, or
1095 // bases, without changing the release proof layout or runtime work.
1096 #[cfg(test)]
1097 pub(super) rest_translation_f32_rounding_demand: f64,
1098 #[cfg(test)]
1099 pub(super) trajectory_f32_rounding_demand: f64,
1100 #[cfg(test)]
1101 pub(super) skin_matrix_f32_rounding_demand: f64,
1102 #[cfg(test)]
1103 pub(super) bounds_f32_rounding_demand: f64,
1104 #[cfg(test)]
1105 pub(super) unaffected_inverse_bind_f32_rounding_demand: f64,
1106}
1107
1108/// Independently re-derive and check every claim [`ScalePlan`] makes.
1109///
1110/// Proof runs on the in-memory candidate, re-deriving world matrices,
1111/// sampled trajectories, skin matrices, and bounds from `source` and
1112/// `candidate` rather than trusting how they were built. Numerical residuals
1113/// use [`ScaleTolerancePolicy::scalar_tolerance`] computed from that
1114/// comparison's own actual before/after magnitudes, never a proxy such as the
1115/// plan's declared factor. Discrete topology and the complete rest-world
1116/// affine outside a rest/bind closure are exact unchanged-domain invariants.
1117/// The world comparison is a semantic placement claim; exact local write-set
1118/// parity is a separate artifact/ledger obligation. Neither `source` nor
1119/// `candidate` need be numerically identical to the document `plan` was
1120/// computed against, but re-deriving `source`'s structural planning inventory
1121/// must produce the same affected domain and proof obligations.
1122///
1123/// # Errors
1124///
1125/// Returns [`ScaleError::PlanDocumentMismatch`] when the supplied source
1126/// derives a different proof inventory, any planning/selector error surfaced
1127/// while re-deriving that inventory, [`ScaleError::CandidateStructureMismatch`]
1128/// when an exact source/candidate invariant differs,
1129/// [`ScaleError::ProofResidualExceeded`] for the first residual that exceeds
1130/// [`ScalePlan::tolerance_policy`], or [`ScaleError::MissingProofEvidence`] if
1131/// an obligation the plan declares provable has no counterpart evidence in
1132/// `candidate`.
1133///
1134/// Two claims checked here are not gated by [`ScaleProofObligation`].
1135/// [`ProofResidualKind::TrackValue`] compares every stored animation element
1136/// with that track domain's analytic expectation: the declared multiplier
1137/// where the plan rewrites the domain, and the retained value where it does
1138/// not. Both branches are owed by every plan.
1139/// [`ProofResidualKind::MeshPosition`] does the same for every base mesh
1140/// `POSITION`. In particular, whole-document conversion rewrites those values
1141/// with [`ScaleRewriteRule::WholeDocumentLength`]; this is not a preservation
1142/// claim. The comparison remains unconditional because skinned bounds would
1143/// otherwise be its only witness, and bounds reports zero comparisons for a
1144/// document with no skinned instance. Neither claim admits an obligation flag
1145/// as a proxy for having run — see [`ScaleProof`], whose comparison counts
1146/// report what each actually walked.
1147///
1148/// [`ScaleProof::observed_factor`] is re-derived here from `source` rather
1149/// than copied from [`ScalePlan::observed_factor`]; it is reported as
1150/// evidence and is not itself an obligation. Both witnesses and the
1151/// divergence between them are recorded
1152/// ([`ScaleProof::planned_observed_factor`],
1153/// [`ScaleProof::observed_factor_divergence`]); none of the three is checked
1154/// against a band here.
1155pub fn prove_scale(
1156 source: &Document,
1157 candidate: &ScaleCandidate,
1158 plan: &ScalePlan,
1159) -> Result<ScaleProof, ScaleError> {
1160 let candidate = candidate.document();
1161 validate_plan_document_inventory(source, plan)?;
1162 validate_scale_input(candidate)?;
1163 validate_candidate_structure(source, candidate)?;
1164 let mut discharged_field_rows = BTreeSet::new();
1165 let tol = plan.tolerance_policy;
1166 let affected = plan.affected_set();
1167 let affected_skin_instances = if plan.has_skin_and_bounds() {
1168 affected_skin_instance_indices(source, &affected)
1169 } else {
1170 Vec::new()
1171 };
1172 let source_worlds = rest_world_pose(&source.skeleton)?;
1173 let candidate_worlds = rest_world_pose(&candidate.skeleton)?;
1174
1175 // Rest/bind rewrites a strict hierarchy domain while promising that every
1176 // bone outside it keeps the same world rest. This is exact, not a new
1177 // tolerance: unchanged placement is the operation's semantic invariant,
1178 // and a relative tolerance would only admit larger and larger displacement
1179 // as authored coordinates grow. Compare the complete affine so an
1180 // in-place rotation or scale mutation cannot hide behind an unchanged
1181 // origin. Exact local-field/write-set parity is intentionally not inferred
1182 // from equal matrices; that belongs to the explicit artifact/ledger layer.
1183 //
1184 // Whole-document conversion has no complement, so the loop is naturally
1185 // empty there; topology parity still applies because that operation does
1186 // not rewrite parents either.
1187 if plan.has_obligation(ScaleProofObligation::ExactUnchangedWorldRest) {
1188 for node in (0..source.skeleton.bones.len()).filter(|node| !affected.contains(node)) {
1189 let before = source_worlds.bone(node)?.matrix;
1190 let after = candidate_worlds.bone(node)?.matrix;
1191 if before != after {
1192 return Err(ScaleError::CandidateStructureMismatch {
1193 reason: "unaffected_world_rest_mismatch",
1194 });
1195 }
1196 }
1197 }
1198 let observed_factor = observed_factor_from_source(source, &source_worlds, plan)?;
1199 let mut proof = ScaleProof {
1200 tolerance_policy: tol,
1201 rest_translation: ScaleProofResidual::EMPTY,
1202 rest_rotation: ScaleProofResidual::EMPTY,
1203 unit_scale: ScaleProofResidual::EMPTY,
1204 transform_only_affine: ScaleProofResidual::EMPTY,
1205 track_value: ScaleProofResidual::EMPTY,
1206 mesh_position: ScaleProofResidual::EMPTY,
1207 key_translation: ScaleProofResidual::EMPTY,
1208 cubic_interior: ScaleProofResidual::EMPTY,
1209 trajectory: ScaleProofResidual::EMPTY,
1210 skin_matrix: ScaleProofResidual::EMPTY,
1211 bounds: ScaleProofResidual::EMPTY,
1212 unaffected_inverse_bind: ScaleProofResidual::EMPTY,
1213 observed_factor,
1214 planned_observed_factor: plan.observed_factor,
1215 observed_factor_divergence: relative_divergence(plan.observed_factor, observed_factor),
1216 sample_time_count: 0,
1217 #[cfg(test)]
1218 rest_translation_f32_rounding_demand: 0.0,
1219 #[cfg(test)]
1220 trajectory_f32_rounding_demand: 0.0,
1221 #[cfg(test)]
1222 skin_matrix_f32_rounding_demand: 0.0,
1223 #[cfg(test)]
1224 bounds_f32_rounding_demand: 0.0,
1225 #[cfg(test)]
1226 unaffected_inverse_bind_f32_rounding_demand: 0.0,
1227 };
1228
1229 check_candidate_values(
1230 source,
1231 candidate,
1232 &affected,
1233 plan,
1234 &tol,
1235 &mut proof,
1236 &mut discharged_field_rows,
1237 )?;
1238
1239 if let Some((rest_nodes, prove_unit_scale)) = plan.rest_obligation() {
1240 for &node in rest_nodes {
1241 let before = source_worlds.bone(node)?.matrix;
1242 let after_pose = candidate_worlds.bone(node)?;
1243 let after = after_pose.matrix;
1244 let after_chain = after_pose.translation_rounding_magnitude;
1245 let (translation_residual, before_mag, after_mag) = rest_node_residual(
1246 before,
1247 after,
1248 plan.is_whole_document(),
1249 plan.common_factor(),
1250 );
1251 // The magnitude is the parent chain's, not the surviving
1252 // translation's: a joint whose local offset points back along its
1253 // parent's world translation leaves a world translation the
1254 // difference of two much larger terms, carrying their rounding
1255 // error into a comparison whose own operands are small.
1256 //
1257 // The *candidate's* chain, not the source's and not the `max` of
1258 // the two. The residual is measured against the candidate's
1259 // arithmetic: whole-document conversion scales every translation
1260 // by the factor and leaves every linear part alone, so the two
1261 // chains are that factor apart — subject to the candidate's `f32`
1262 // narrowing of the factor, since the build scales by
1263 // `factor as f32` while this proof rebases by the `f64` factor, a
1264 // relative difference of at most `2^-24` (`1.49e-8` at `q = 0.1`,
1265 // whose `f32` is `0.10000000149011612`) that the rounding term
1266 // covers many times over — and the source's rounding is rebased by
1267 // the same factor before it is compared. The
1268 // residual therefore scales with `after_chain` at either end of
1269 // the factor range, and under rest/bind the two chains are equal
1270 // outright. `before_chain` can only ever over-provide — and under
1271 // a *shrinking* conversion it over-provides by `1/factor`, freezing
1272 // the band at the source rig's size while the candidate the band
1273 // is spent on gets smaller without limit.
1274 //
1275 // `a_cancelling_chain_under_conversion_holds_rest_translation_to_the_candidate_side`
1276 // pins that reading the *source* side alone refuses a correct
1277 // candidate under a growing conversion;
1278 // `a_shrinking_conversion_holds_rest_translation_to_the_candidate_s_own_chain`
1279 // pins the opposite direction, where the source side is the larger
1280 // one and reading it admits a `100x` larger build error; and
1281 // `the_rest_translation_v6_floor_is_an_adjacent_f32_transition`
1282 // pins the size of the term from above.
1283 check_and_track_f32_rounded(
1284 ProofResidualKind::RestTranslation,
1285 translation_residual,
1286 before_mag,
1287 after_mag,
1288 after_chain,
1289 &tol,
1290 &mut proof,
1291 )?;
1292 // Both operations leave every node's *local* rotation field
1293 // byte-identical (translation and scale are the only rewritten
1294 // channels), so proving world orientation preservation by
1295 // comparing local rotations directly avoids a lossy matrix
1296 // decomposition and, by composition, implies preserved world
1297 // orientation for every node in the chain.
1298 //
1299 // This is therefore an *equality* test on a field no build path
1300 // writes, not an angle measurement — and it must not be spelled
1301 // as one. `Quat::angle_between` does not normalize its operands,
1302 // so an authored quaternion with `|q| = 1 - eps` (routine in
1303 // glTF, and which `invariant-9` forbids the loader from
1304 // renormalizing) reports roughly `2 * sqrt(4 * eps)` against
1305 // itself: the perfectly ordinary key `[0, 0.7071067, 0,
1306 // 0.7071067]` measures `1.2e-3` against an identical copy, `120x`
1307 // the `1e-5` tolerance, and a correct candidate for a real rig is
1308 // rejected as `RestRotation`.
1309 let source_rotation = source
1310 .skeleton
1311 .bones
1312 .get(node)
1313 .ok_or(ScaleError::BoneIndexOutOfRange { index: node })?
1314 .rest
1315 .rotation;
1316 let candidate_rotation = candidate
1317 .skeleton
1318 .bones
1319 .get(node)
1320 .ok_or(ScaleError::BoneIndexOutOfRange { index: node })?
1321 .rest
1322 .rotation;
1323 // [`quat_equality_residual`] answers in *chord* space, and this
1324 // obligation's declared bound is an angle, so the chord is
1325 // converted to the angle it represents before it is either
1326 // reported or compared (see [`quat_residual_radians`]).
1327 let rotation_residual =
1328 quat_residual_radians(quat_equality_residual(source_rotation, candidate_rotation));
1329 record_and_check(
1330 ProofResidualKind::RestRotation,
1331 rotation_residual,
1332 tol.rotation_residual_radians,
1333 &mut proof,
1334 )?;
1335 if prove_unit_scale {
1336 let (after_scale, ..) = after.to_scale_rotation_translation();
1337 // Per-axis (L-infinity), per
1338 // [`ScaleTolerancePolicy::postcondition_unit_scale_residual`]:
1339 // "unit composed scale for every affected node" (DESIGN.md
1340 // Appendix D §D.6) is a per-axis claim, and measuring it
1341 // per-axis is what makes it commensurable with the scalar
1342 // relative common-factor band that gates the input. An L2
1343 // norm over the three axes reports `sqrt(3)` times the same
1344 // defect and therefore rejected candidates the very same
1345 // policy's input band had just accepted.
1346 let residual = (after_scale.x as f64 - 1.0)
1347 .abs()
1348 .max((after_scale.y as f64 - 1.0).abs())
1349 .max((after_scale.z as f64 - 1.0).abs());
1350 record_and_check(
1351 ProofResidualKind::UnitScale,
1352 residual,
1353 tol.postcondition_unit_scale_residual,
1354 &mut proof,
1355 )?;
1356 }
1357 }
1358 }
1359 // The rest-world handler above is the semantic owner of all normalized
1360 // rest containers. Translation/rotation have direct residuals; scale is
1361 // intentionally owned at composed-world level (and, for rest/bind, by
1362 // the unit-scale postcondition) rather than by a new local-value band.
1363 for (row_index, row) in plan.field_rows().iter().enumerate() {
1364 if matches!(row.target, ScaleFieldTarget::BoneRest { .. }) {
1365 mark_field_row_discharged(&mut discharged_field_rows, row_index)?;
1366 }
1367 }
1368
1369 if let Some(transform_only_nodes) = plan.transform_only_nodes() {
1370 // scale(1/s): the analytically expected basis correction `C_i` for
1371 // every node inside the affected domain (DESIGN.md Appendix D §D.2).
1372 let correction = Mat4::from_scale(Vec3::splat((1.0 / plan.common_factor()) as f32));
1373 // A fixed off-origin local probe point: transforming it through the
1374 // complete expected/actual world affine — rather than decomposing
1375 // to translation/rotation and checking only those — is what makes a
1376 // no-op (or any build that drops the linear-scale channel) provably
1377 // fail this check.
1378 let probe = Vec3::ONE;
1379 for &node in transform_only_nodes {
1380 let before = source_worlds.bone(node)?.matrix;
1381 let after = candidate_worlds.bone(node)?.matrix;
1382 let expected_point = (before * correction).transform_point3(probe).as_dvec3();
1383 let actual_point = after.transform_point3(probe).as_dvec3();
1384 let residual = (actual_point - expected_point).length();
1385 check_and_track(
1386 ProofResidualKind::TransformOnlyAffine,
1387 residual,
1388 expected_point.length(),
1389 actual_point.length(),
1390 &tol,
1391 &mut proof,
1392 )?;
1393 }
1394 }
1395
1396 check_skin_and_bounds(
1397 source,
1398 candidate,
1399 &source_worlds,
1400 &candidate_worlds,
1401 &affected_skin_instances,
1402 plan,
1403 &tol,
1404 &mut proof,
1405 )?;
1406 if plan.has_unaffected_binds() {
1407 check_unaffected_instance_binds(source, candidate, &affected, &tol, &mut proof)?;
1408 }
1409 // The shared skin walk and unaffected-bind walk are the existing numeric
1410 // owners for effective slot binds. Bone convenience binds that are
1411 // unreferenced or shadowed, plus normals that neither scale operation
1412 // writes, remain ownership-only rows: changing their numeric policy here
1413 // would alter the accepted set.
1414 for (row_index, row) in plan.field_rows().iter().enumerate() {
1415 if matches!(
1416 row.target,
1417 ScaleFieldTarget::BoneInverseBind { .. }
1418 | ScaleFieldTarget::InstanceInverseBind { .. }
1419 | ScaleFieldTarget::MeshNormals { .. }
1420 ) {
1421 mark_field_row_discharged(&mut discharged_field_rows, row_index)?;
1422 }
1423 }
1424
1425 let any_sampled_obligation = plan.has_key_translations()
1426 || plan.has_cubic_interiors()
1427 || plan.trajectory_nodes().is_some()
1428 || plan.has_skin_and_bounds();
1429 if any_sampled_obligation {
1430 // Harvested once, up front, for two reasons: the budget below has to
1431 // know the total sample count *before* the first sample is evaluated,
1432 // and re-harvesting per clip inside the loop would sort and dedup the
1433 // same key times twice.
1434 let mut clip_times = Vec::with_capacity(source.clips.len());
1435 let mut sample_times: u64 = 0;
1436 for clip in &source.clips {
1437 let times = clip_sample_times(clip, &affected);
1438 sample_times = sample_times
1439 .saturating_add(times.0.len() as u64)
1440 .saturating_add(times.1.len() as u64);
1441 clip_times.push(times);
1442 }
1443 let per_sample_cost = per_sample_work_units(source, &affected_skin_instances);
1444 check_sampling_budget(&tol, sample_times, per_sample_cost)?;
1445
1446 for (clip_index, clip) in source.clips.iter().enumerate() {
1447 let candidate_clip =
1448 candidate
1449 .clips
1450 .get(clip_index)
1451 .ok_or(ScaleError::MissingProofEvidence {
1452 kind: ProofResidualKind::KeyTranslation,
1453 detail: "candidate_clip_missing",
1454 })?;
1455 let (key_times, interior_times) = &clip_times[clip_index];
1456 for &t in key_times {
1457 proof.sample_time_count += 1;
1458 if plan.has_key_translations() {
1459 check_track_value_residual(
1460 ProofResidualKind::KeyTranslation,
1461 source,
1462 clip,
1463 candidate_clip,
1464 &affected,
1465 t,
1466 plan,
1467 &tol,
1468 &mut proof,
1469 )?;
1470 }
1471 sample_time_obligations(
1472 source,
1473 candidate,
1474 clip,
1475 candidate_clip,
1476 t,
1477 &affected_skin_instances,
1478 plan,
1479 &tol,
1480 &mut proof,
1481 )?;
1482 }
1483 for &t in interior_times {
1484 proof.sample_time_count += 1;
1485 if plan.has_cubic_interiors() {
1486 check_track_value_residual(
1487 ProofResidualKind::CubicInterior,
1488 source,
1489 clip,
1490 candidate_clip,
1491 &affected,
1492 t,
1493 plan,
1494 &tol,
1495 &mut proof,
1496 )?;
1497 }
1498 sample_time_obligations(
1499 source,
1500 candidate,
1501 clip,
1502 candidate_clip,
1503 t,
1504 &affected_skin_instances,
1505 plan,
1506 &tol,
1507 &mut proof,
1508 )?;
1509 }
1510 }
1511 }
1512
1513 check_rewritten_source_field_dispositions(
1514 source,
1515 candidate,
1516 plan,
1517 &affected,
1518 &tol,
1519 &mut discharged_field_rows,
1520 )?;
1521 check_preserved_field_dispositions(source, candidate, plan, &mut discharged_field_rows)?;
1522 finish_field_row_discharge(plan, &discharged_field_rows)?;
1523 Ok(proof)
1524}
1525
1526/// Every obligation one sample time owes, evaluated against **one** pair of
1527/// world-matrix arrays.
1528///
1529/// The trajectory, skin, and bounds obligations all need the same source and
1530/// candidate poses at `t`. Deriving them once here — rather than letting each
1531/// obligation call [`world_at_time`] for itself, which recomputed forward
1532/// kinematics up to three times per sample per document — is what keeps the
1533/// proof's cost linear in the sample count rather than a fixed multiple of
1534/// it, and is a precondition for the sampling budget in [`prove_scale`] being
1535/// a meaningful bound on real work.
1536#[allow(clippy::too_many_arguments)]
1537fn sample_time_obligations(
1538 source: &Document,
1539 candidate: &Document,
1540 source_clip: &Clip,
1541 candidate_clip: &Clip,
1542 t: f32,
1543 affected_skin_instances: &[usize],
1544 plan: &ScalePlan,
1545 tol: &ScaleTolerancePolicy,
1546 proof: &mut ScaleProof,
1547) -> Result<(), ScaleError> {
1548 if plan.trajectory_nodes().is_none() && !plan.has_skin_and_bounds() {
1549 return Ok(());
1550 }
1551 let source_worlds = world_at_time(&source.skeleton, source_clip, t)?;
1552 let candidate_worlds = world_at_time(&candidate.skeleton, candidate_clip, t)?;
1553 if let Some(nodes) = plan.trajectory_nodes() {
1554 check_trajectory_residual_at(&source_worlds, &candidate_worlds, nodes, plan, tol, proof)?;
1555 }
1556 check_skin_and_bounds(
1557 source,
1558 candidate,
1559 &source_worlds,
1560 &candidate_worlds,
1561 affected_skin_instances,
1562 plan,
1563 tol,
1564 proof,
1565 )
1566}
1567
1568/// The two document sides — source and candidate — every sampled obligation
1569/// walks. [`sample_time_obligations`] poses both skeletons, and
1570/// [`check_skin_and_bounds`] resolves both slot palettes and skins both
1571/// vertex arrays, so every term of [`per_sample_work_units`] is charged twice.
1572const PROOF_SIDES: u64 = 2;
1573
1574/// Refuse a document whose total sampled work exceeds
1575/// [`ScaleTolerancePolicy::proof_sample_work_budget`], before the first
1576/// sample time is evaluated.
1577///
1578/// A free function rather than an inline comparison in [`prove_scale`] so
1579/// that the boundary itself is directly testable on synthetic numbers. The
1580/// budget is a ceiling the document may *reach*: the comparison is `>`, not
1581/// `>=`, exactly as [`check_residual`]'s is, and for the same reason —
1582/// DESIGN.md Appendix D §D.1 states every policy quantity as an inclusive
1583/// "at most". Pinning that end to end would mean a document that then costs
1584/// `1e8` work units to prove; pinning it here costs nothing and asserts the
1585/// same thing.
1586///
1587/// # Errors
1588///
1589/// Returns [`ScaleError::ProofSamplingBudgetExceeded`] carrying both factors
1590/// and the product, so the caller can see which of the two is oversized.
1591pub(super) fn check_sampling_budget(
1592 tol: &ScaleTolerancePolicy,
1593 sample_times: u64,
1594 per_sample_cost: u64,
1595) -> Result<(), ScaleError> {
1596 let work = sample_times.saturating_mul(per_sample_cost);
1597 if work > tol.proof_sample_work_budget {
1598 return Err(ScaleError::ProofSamplingBudgetExceeded {
1599 policy_id: tol.id,
1600 sample_times,
1601 per_sample_cost,
1602 work,
1603 budget: tol.proof_sample_work_budget,
1604 });
1605 }
1606 Ok(())
1607}
1608
1609/// Work units one sample time costs, for
1610/// [`ScaleTolerancePolicy::proof_sample_work_budget`].
1611///
1612/// The charge is what [`sample_time_obligations`] and
1613/// [`check_skin_and_bounds`] actually perform at one sample time, term by
1614/// term. Everything they walk, they walk for **both** document sides, so
1615/// every term below carries the [`PROOF_SIDES`] factor:
1616///
1617/// - one forward-kinematics pass over the skeleton per side, owed by every
1618/// sampled obligation — hence `2 * bone_count`, always charged. Only the
1619/// *source* skeleton is measured here, which is sound only because
1620/// [`validate_candidate_structure`] has already rejected a candidate whose
1621/// bone count differs; see the note on its `bone_count_mismatch` clause for
1622/// what an unchecked candidate skeleton cost;
1623/// - per affected skinned instance, one `world * inverse_bind` product per
1624/// [`crate::model::MeshInstance::skin_joints`] slot per side, plus one residual
1625/// comparison per slot when the skin obligation is declared; and
1626/// - per affected skinned instance, every vertex of **every** primitive of
1627/// its mesh per side, when the bounds obligation is declared.
1628///
1629/// The slot term is charged explicitly because nothing bounds it and nothing
1630/// else stands in for it. An earlier revision charged only `bone_count +
1631/// vertices` on the claim that slot work "cannot exceed the bone count",
1632/// which is false twice over: [`validate_scale_input`] only range-checks
1633/// joint ids, so `skin_joints` may repeat a joint and be arbitrarily long,
1634/// and the instance count is unbounded, so the total is
1635/// `sum over instances of len(skin_joints)` with no relation to
1636/// `bone_count` at all. A legal 400-instance document with 300 slots each and
1637/// one vertex per instance was charged `120_600` while performing `36_000_000`
1638/// slot matrix products — a `299x` undercount, and unbounded in general.
1639///
1640/// This bounds the *sampled* work, which is what grows with the document's
1641/// key count. [`prove_scale`] additionally evaluates the rest pose once,
1642/// outside the sampled loop and outside this budget; that is one extra pose
1643/// of the same shape, not a term that scales with anything.
1644pub(super) fn per_sample_work_units(document: &Document, affected_skin_instances: &[usize]) -> u64 {
1645 let mut units = PROOF_SIDES.saturating_mul(document.skeleton.bones.len() as u64);
1646 for &instance_index in affected_skin_instances {
1647 let instance = &document.assets.instances[instance_index];
1648 let slots = instance.skin_joints.len() as u64;
1649 units = units.saturating_add(PROOF_SIDES.saturating_mul(slots));
1650 units = units.saturating_add(slots);
1651 let Some(mesh) = document.assets.meshes.get(instance.mesh) else {
1652 continue;
1653 };
1654 for primitive in &mesh.primitives {
1655 units =
1656 units.saturating_add(PROOF_SIDES.saturating_mul(primitive.positions.len() as u64));
1657 }
1658 }
1659 units
1660}
1661
1662/// Fail closed on any residual that is not provably within `tolerance`.
1663///
1664/// The non-finite guard is load-bearing, not defensive noise: `NaN > x` is
1665/// `false` for every `x`, so a bare `observed > tolerance` reports a `NaN`
1666/// residual — the exact signature of a candidate built with an overflowing
1667/// factor, or of a comparison against a non-finite source value — as a pass.
1668/// A `NaN` tolerance (from a non-finite before/after magnitude) fails the
1669/// same way and is rejected for the same reason.
1670///
1671/// The guard is `!observed.is_finite()` rather than `observed.is_nan()`, and
1672/// the difference is narrower than it looks: `+inf > tolerance` is true, so
1673/// the comparison alone already rejects a positive infinity. Only a
1674/// *negative* non-finite residual needs the wider guard, and no caller in
1675/// this module can produce one — every `observed` here is an `abs()`, a
1676/// `length()`, or a `max` fold over those. The wider spelling is kept because
1677/// this function's contract is the fail-closed one stated above rather than
1678/// "whatever today's callers happen to pass", and it is pinned by
1679/// `a_non_finite_residual_fails_closed_instead_of_comparing_false`.
1680pub(super) fn check_residual(
1681 kind: ProofResidualKind,
1682 observed: f64,
1683 tolerance: f64,
1684) -> Result<(), ScaleError> {
1685 if !observed.is_finite() || !tolerance.is_finite() || observed > tolerance {
1686 return Err(ScaleError::ProofResidualExceeded {
1687 kind,
1688 observed,
1689 tolerance,
1690 });
1691 }
1692 Ok(())
1693}
1694
1695impl ScaleProof {
1696 /// Record the raw ulp demand of one f32-rounded comparison at the exact
1697 /// point its residual and rounding base meet.
1698 ///
1699 /// This is calibration instrumentation, not published evidence. A zero
1700 /// base and zero residual make no demand on the rounding count; a nonzero
1701 /// residual with no provenance records infinity so calibration fails
1702 /// closed instead of silently reporting zero.
1703 #[cfg(test)]
1704 pub(super) fn record_f32_rounding_demand(
1705 &mut self,
1706 kind: ProofResidualKind,
1707 observed: f64,
1708 magnitude: f64,
1709 ) {
1710 let demand = if magnitude > 0.0 {
1711 observed / (magnitude * f64::from(f32::EPSILON))
1712 } else if observed == 0.0 {
1713 0.0
1714 } else {
1715 f64::INFINITY
1716 };
1717 let slot = match kind {
1718 ProofResidualKind::RestTranslation => &mut self.rest_translation_f32_rounding_demand,
1719 ProofResidualKind::Trajectory => &mut self.trajectory_f32_rounding_demand,
1720 ProofResidualKind::SkinMatrix => &mut self.skin_matrix_f32_rounding_demand,
1721 ProofResidualKind::Bounds => &mut self.bounds_f32_rounding_demand,
1722 ProofResidualKind::UnaffectedInverseBind => {
1723 &mut self.unaffected_inverse_bind_f32_rounding_demand
1724 }
1725 _ => unreachable!("only f32-rounded residual kinds record a raw rounding demand"),
1726 };
1727 *slot = slot.max(demand);
1728 }
1729
1730 /// The maximum/count pair this residual kind reports into.
1731 ///
1732 /// The single mapping from a [`ProofResidualKind`] to the fields it
1733 /// writes. Every comparison site names its kind and nothing else, so a
1734 /// site cannot report one kind's residual into another kind's field —
1735 /// which was previously possible wherever a `&mut f64` and a `kind` were
1736 /// passed as independent arguments.
1737 ///
1738 /// [`ProofResidualKind::ObservedFactor`] is not a residual — it names a
1739 /// source whose scaled root could not be resolved, and is only ever
1740 /// reported as [`ScaleError::MissingProofEvidence`] — so it has no pair
1741 /// and no comparison site reaches here with it.
1742 fn tally(&mut self, kind: ProofResidualKind) -> Option<&mut ScaleProofResidual> {
1743 let tally = match kind {
1744 ProofResidualKind::RestTranslation => &mut self.rest_translation,
1745 ProofResidualKind::RestRotation => &mut self.rest_rotation,
1746 ProofResidualKind::UnitScale => &mut self.unit_scale,
1747 ProofResidualKind::TransformOnlyAffine => &mut self.transform_only_affine,
1748 ProofResidualKind::TrackValue => &mut self.track_value,
1749 ProofResidualKind::MeshPosition => &mut self.mesh_position,
1750 ProofResidualKind::KeyTranslation => &mut self.key_translation,
1751 ProofResidualKind::CubicInterior => &mut self.cubic_interior,
1752 ProofResidualKind::Trajectory => &mut self.trajectory,
1753 ProofResidualKind::SkinMatrix => &mut self.skin_matrix,
1754 ProofResidualKind::Bounds => &mut self.bounds,
1755 ProofResidualKind::UnaffectedInverseBind => &mut self.unaffected_inverse_bind,
1756 ProofResidualKind::ObservedFactor => return None,
1757 };
1758 Some(tally)
1759 }
1760}
1761
1762/// Record one comparison of `observed` for `kind` and check it against
1763/// `tolerance`.
1764///
1765/// The single point at which a residual maximum moves. Recording and
1766/// checking here — rather than at each of the twelve obligations' loops —
1767/// is what makes [`ScaleProof`]'s counts describe exactly the comparisons
1768/// its maxima were taken over.
1769fn record_and_check(
1770 kind: ProofResidualKind,
1771 observed: f64,
1772 tolerance: f64,
1773 proof: &mut ScaleProof,
1774) -> Result<(), ScaleError> {
1775 if let Some(tally) = proof.tally(kind) {
1776 tally.record(observed);
1777 }
1778 check_residual(kind, observed, tolerance)
1779}
1780
1781/// Record `observed` for `kind` and check it against the
1782/// before/after-derived tolerance for this specific comparison — never a
1783/// proxy such as the plan's declared factor.
1784fn check_and_track(
1785 kind: ProofResidualKind,
1786 observed: f64,
1787 before: f64,
1788 after: f64,
1789 tol: &ScaleTolerancePolicy,
1790 proof: &mut ScaleProof,
1791) -> Result<(), ScaleError> {
1792 record_and_check(kind, observed, tol.scalar_tolerance(before, after), proof)
1793}
1794
1795/// [`check_and_track`] for a residual between two `f32`-rounded quantities,
1796/// carrying the `magnitude` their arithmetic actually ran on.
1797///
1798/// Only the five obligations whose compared quantity can be made arbitrarily
1799/// smaller than that magnitude by a rotation use this — see
1800/// [`ScaleTolerancePolicy::f32_rounding_ulps`]. Every other obligation
1801/// compares a vector length or a matrix entry against its own magnitude,
1802/// where the two are the same number and the extra term would be noise.
1803fn check_and_track_f32_rounded(
1804 kind: ProofResidualKind,
1805 observed: f64,
1806 before: f64,
1807 after: f64,
1808 magnitude: f64,
1809 tol: &ScaleTolerancePolicy,
1810 proof: &mut ScaleProof,
1811) -> Result<(), ScaleError> {
1812 #[cfg(test)]
1813 proof.record_f32_rounding_demand(kind, observed, magnitude);
1814 record_and_check(
1815 kind,
1816 observed,
1817 tol.f32_rounded_tolerance(before, after, magnitude),
1818 proof,
1819 )
1820}
1821
1822/// Rest-world translation residual for one node, plus the expected/actual
1823/// translation magnitudes the caller uses to derive this comparison's own
1824/// tolerance.
1825///
1826/// This deliberately does not also report a rotation residual: extracting a
1827/// rotation via [`Mat4::to_scale_rotation_translation`] out of a world
1828/// matrix whose linear part mixes a small uniform scale with an actual
1829/// rotation is numerically fragile in `f32`, and unnecessary here — both
1830/// operations leave every node's *local* rotation field untouched, so
1831/// callers that need a rotation residual should compare
1832/// [`crate::model::Bone::rest`]`.rotation` directly (see [`prove_scale`]),
1833/// which is both exact and, by composition, implies preserved world
1834/// orientation.
1835fn rest_node_residual(
1836 before: Mat4,
1837 after: Mat4,
1838 whole_document: bool,
1839 factor: f64,
1840) -> (f64, f64, f64) {
1841 let (_, _, before_translation) = before.to_scale_rotation_translation();
1842 let (_, _, after_translation) = after.to_scale_rotation_translation();
1843 let expected_translation = if whole_document {
1844 before_translation.as_dvec3() * factor
1845 } else {
1846 before_translation.as_dvec3()
1847 };
1848 let actual_translation = after_translation.as_dvec3();
1849 let translation_residual = (actual_translation - expected_translation).length();
1850 (
1851 translation_residual,
1852 expected_translation.length(),
1853 actual_translation.length(),
1854 )
1855}
1856
1857/// The multiplier this plan analytically expects a given node's translation
1858/// values to have been rewritten by.
1859///
1860/// Whole-document conversion multiplies every translation by the declared
1861/// factor. Rest/bind reparameterization multiplies by the target node's
1862/// *parent-basis* factor: the domain's common factor when the node's parent
1863/// is itself affected, the unaffected boundary factor of one otherwise.
1864fn translation_multiplier(
1865 document: &Document,
1866 node: BoneId,
1867 affected: &BTreeSet<BoneId>,
1868 plan: &ScalePlan,
1869) -> f64 {
1870 if plan.is_whole_document() {
1871 return plan.common_factor();
1872 }
1873 if !affected.contains(&node) {
1874 return 1.0;
1875 }
1876 match document
1877 .skeleton
1878 .bones
1879 .get(node)
1880 .and_then(|bone| bone.parent)
1881 {
1882 Some(parent) if affected.contains(&parent) => plan.common_factor(),
1883 _ => 1.0,
1884 }
1885}
1886
1887/// Independently derive the scale-track boundary root the proof expects.
1888///
1889/// This deliberately does not call the reference writer's scale-animation
1890/// multiplier: the builder and proof must derive the selected-root boundary
1891/// separately, or a wrong builder helper could leave a root scale track
1892/// unchanged and teach the proof to accept it.
1893fn proof_scale_animation_root(
1894 source: &Document,
1895 plan: &ScalePlan,
1896) -> Result<Option<BoneId>, ScaleError> {
1897 let ScaleOperation::RestBindUniformScale {
1898 source_root_node_index,
1899 ..
1900 } = plan.operation()
1901 else {
1902 return Ok(None);
1903 };
1904 // Unlike the builder's parent/affected-boundary derivation, proof starts
1905 // from the operation's authored root selector and the source projection.
1906 // Agreement therefore requires two independent descriptions of which
1907 // bone owns the only non-unit local scale multiplier.
1908 let selected_root = source
1909 .assets
1910 .source_skeleton
1911 .nodes
1912 .iter()
1913 .find(|asset| asset.source_node_index == source_root_node_index)
1914 .and_then(|asset| asset.bone)
1915 .ok_or(ScaleError::PlanDocumentMismatch {
1916 reason: "selected_root_projection_mismatch",
1917 })?;
1918 Ok(Some(selected_root))
1919}
1920
1921/// Prove every retained per-element payload directly, not merely its shape.
1922///
1923/// [`validate_candidate_structure`] establishes that source and candidate
1924/// agree on clip/track/instance/mesh/primitive *counts* and on each track's
1925/// `(bone, property, interpolation, times)` identity — but it never looks
1926/// inside `values` or `positions`. Both are reachable through this module's
1927/// public API without any structural mismatch:
1928/// [`ScaleCandidate::from_document`] accepts an external document
1929/// independently of the source supplied to [`prove_scale`], so a doctored
1930/// candidate can be proved against the real source. Without a direct
1931/// comparison a rotation key rewritten from `0.1` to `2.5` radians, or an
1932/// interior mesh vertex moved anywhere at all, passes proof: the sampled
1933/// obligations only look at translation, world *joint* transforms, and the
1934/// bounding box's extreme vertices.
1935///
1936/// So every element of every domain is checked here against its analytic
1937/// expectation: rewritten domains against `before * multiplier` and
1938/// non-rewritten domains against `before` itself. Comparison is by
1939/// [`ScaleTolerancePolicy::scalar_tolerance`], never exact float equality,
1940/// which DESIGN.md Appendix D §D.1 forbids.
1941fn check_candidate_values(
1942 source: &Document,
1943 candidate: &Document,
1944 affected: &BTreeSet<BoneId>,
1945 plan: &ScalePlan,
1946 tol: &ScaleTolerancePolicy,
1947 proof: &mut ScaleProof,
1948 discharged: &mut BTreeSet<usize>,
1949) -> Result<(), ScaleError> {
1950 let proof_scale_root = proof_scale_animation_root(source, plan)?;
1951 for (row_index, row) in plan.field_rows().iter().enumerate() {
1952 match row.target {
1953 ScaleFieldTarget::AnimationValues {
1954 clip_index,
1955 track_index,
1956 bone,
1957 property,
1958 } => {
1959 let track = &source.clips[clip_index].tracks[track_index];
1960 let candidate_track = &candidate.clips[clip_index].tracks[track_index];
1961 if track.bone != bone || track.property != property {
1962 return Err(ScaleError::PlanDocumentMismatch {
1963 reason: "compiled_animation_target_mismatch",
1964 });
1965 }
1966 match (&track.values, &candidate_track.values) {
1967 (TrackValues::Vec3s(before), TrackValues::Vec3s(after)) => {
1968 let multiplier = match row.disposition {
1969 ScaleFieldDisposition::PreserveExact => 1.0,
1970 ScaleFieldDisposition::Rewrite(
1971 ScaleRewriteRule::WholeDocumentLength,
1972 ) => plan.common_factor(),
1973 ScaleFieldDisposition::Rewrite(
1974 ScaleRewriteRule::RestBindParentBasis,
1975 ) => translation_multiplier(source, bone, affected, plan),
1976 ScaleFieldDisposition::Rewrite(
1977 ScaleRewriteRule::RestBindLocalScale,
1978 ) => {
1979 if proof_scale_root == Some(bone) {
1980 1.0 / plan.common_factor()
1981 } else {
1982 1.0
1983 }
1984 }
1985 ScaleFieldDisposition::Rewrite(
1986 ScaleRewriteRule::RestBindNodeBasis
1987 | ScaleRewriteRule::RestBindSourceLocal { .. },
1988 ) => {
1989 return Err(ScaleError::PlanDocumentMismatch {
1990 reason: "invalid_animation_rewrite_rule",
1991 });
1992 }
1993 };
1994 for (before, after) in before.iter().zip(after.iter()) {
1995 let expected = before.as_dvec3() * multiplier;
1996 let actual = after.as_dvec3();
1997 let residual = (actual - expected).length();
1998 check_and_track(
1999 ProofResidualKind::TrackValue,
2000 residual,
2001 expected.length(),
2002 actual.length(),
2003 tol,
2004 proof,
2005 )?;
2006 }
2007 }
2008 (TrackValues::Quats(before), TrackValues::Quats(after)) => {
2009 for (before, after) in before.iter().zip(after.iter()) {
2010 let residual = quat_equality_residual(*before, *after);
2011 check_and_track(
2012 ProofResidualKind::TrackValue,
2013 residual,
2014 before.length() as f64,
2015 after.length() as f64,
2016 tol,
2017 proof,
2018 )?;
2019 }
2020 }
2021 _ => {
2022 return Err(ScaleError::CandidateStructureMismatch {
2023 reason: "track_value_variant_mismatch",
2024 });
2025 }
2026 }
2027 mark_field_row_discharged(discharged, row_index)?;
2028 }
2029 ScaleFieldTarget::MeshPositions {
2030 mesh_index,
2031 primitive_index,
2032 } => {
2033 // Base `POSITION` is proved per primitive, directly. Proving
2034 // it only through skinned bounds would miss interior vertices
2035 // and every unskinned instance.
2036 let source_primitive =
2037 &source.assets.meshes[mesh_index].primitives[primitive_index];
2038 let candidate_primitive =
2039 &candidate.assets.meshes[mesh_index].primitives[primitive_index];
2040 let position_multiplier = match row.disposition {
2041 ScaleFieldDisposition::PreserveExact => 1.0,
2042 ScaleFieldDisposition::Rewrite(ScaleRewriteRule::WholeDocumentLength) => {
2043 plan.common_factor()
2044 }
2045 ScaleFieldDisposition::Rewrite(_) => {
2046 return Err(ScaleError::PlanDocumentMismatch {
2047 reason: "invalid_mesh_position_rewrite_rule",
2048 });
2049 }
2050 };
2051 for (before, after) in source_primitive
2052 .positions
2053 .iter()
2054 .zip(candidate_primitive.positions.iter())
2055 {
2056 let expected = before.as_dvec3() * position_multiplier;
2057 let actual = after.as_dvec3();
2058 let residual = (actual - expected).length();
2059 check_and_track(
2060 ProofResidualKind::MeshPosition,
2061 residual,
2062 expected.length(),
2063 actual.length(),
2064 tol,
2065 proof,
2066 )?;
2067 }
2068 mark_field_row_discharged(discharged, row_index)?;
2069 }
2070 _ => {}
2071 }
2072 }
2073 Ok(())
2074}
2075
2076/// Double-cover-aware component distance between two quaternion *values*,
2077/// computed in `f64`.
2078///
2079/// `q` and `-q` denote the same rotation, so the residual is the smaller of
2080/// the two component distances. Deliberately not an angle: nothing here
2081/// normalizes, divides, or takes an inverse cosine, so an authored
2082/// quaternion whose magnitude is not exactly one — which `invariant-9`
2083/// requires loaders to preserve — compares equal to an untouched copy of
2084/// itself at exactly `0.0` rather than at a magnitude-dependent artefact.
2085fn quat_equality_residual(before: Quat, after: Quat) -> f64 {
2086 let before = before.as_dquat();
2087 let after = after.as_dquat();
2088 (before - after).length().min((before + after).length())
2089}
2090
2091/// Convert the chord length [`quat_equality_residual`] reports into the
2092/// shortest-path rotation angle it represents, in radians.
2093///
2094/// For unit quaternions `q1 . q2 = cos(theta / 2)`, so
2095/// `|q1 - q2|^2 = 2 - 2 * cos(theta / 2) = 4 * sin(theta / 4)^2` and the
2096/// chord is `2 * sin(theta / 4)`, which is `theta / 2` to first order.
2097/// Comparing that chord directly against
2098/// [`ScaleTolerancePolicy::rotation_residual_radians`] therefore accepted
2099/// *twice* the declared angle: a genuine `2e-5 rad` error measured
2100/// `9.99e-6` against a `1e-5` policy and passed. Inverting the relation
2101/// gives `theta = 4 * asin(chord / 2)`.
2102///
2103/// Converting here — rather than comparing in chord space, or renaming the
2104/// reported field to say "chord" — is the choice that keeps the public
2105/// evidence contract honest. DESIGN.md Appendix D §D.1 declares the bound as
2106/// "shortest-path rotation residual is at most `1e-5` radians", §D.6 requires
2107/// evidence to publish the tolerance policy *and* the observed residuals
2108/// together, and [`ScaleProof::rest_rotation`] is headed for the
2109/// immutable evidence format. A chord-valued residual sitting next to a
2110/// radian-valued policy in that record would hand every reader the same
2111/// factor-of-two misreading this conversion removes. Converting once, up
2112/// front, also keeps the comparison, the tracked maximum, and
2113/// [`ScaleError::ProofResidualExceeded`]'s `observed`/`tolerance` pair all in
2114/// one unit.
2115///
2116/// The conversion is monotone over the whole reachable chord range, so it
2117/// changes which residuals are accepted only by the intended factor of two,
2118/// never by re-ordering them. The clamp covers a chord above `2`, which no
2119/// pair of unit quaternions can produce (the double-cover minimum is at most
2120/// `sqrt(2)`) but an authored non-unit value can: saturating at `2 * pi`
2121/// fails closed on such a pair instead of reporting a `NaN` that only the
2122/// non-finite guard in [`check_residual`] would catch.
2123fn quat_residual_radians(chord: f64) -> f64 {
2124 4.0 * (chord / 2.0).min(1.0).asin()
2125}
2126
2127/// Harvest the times every sampled obligation is evaluated at: every key
2128/// time of every animated track on an affected bone, plus the analytic
2129/// mid-segment interior of each cubic segment.
2130///
2131/// Deliberately *not* restricted to translation tracks. The sampled
2132/// obligations these times feed — trajectories, the skin equation, and
2133/// bounds — depend on a node's complete animated pose, so a clip that
2134/// animates an affected joint's rotation but not its translation would
2135/// otherwise yield zero sample times and make every sampled obligation
2136/// vacuously true while still reporting success.
2137fn clip_sample_times(clip: &Clip, affected: &BTreeSet<BoneId>) -> (Vec<f32>, Vec<f32>) {
2138 let mut keys = Vec::new();
2139 let mut interiors = Vec::new();
2140 for track in &clip.tracks {
2141 if !affected.contains(&track.bone) {
2142 continue;
2143 }
2144 keys.extend_from_slice(&track.times);
2145 if track.interpolation == Interpolation::CubicSpline {
2146 for window in track.times.windows(2) {
2147 interiors.push((window[0] + window[1]) * 0.5);
2148 }
2149 }
2150 }
2151 keys.sort_by(f32::total_cmp);
2152 keys.dedup();
2153 interiors.sort_by(f32::total_cmp);
2154 interiors.dedup();
2155 (keys, interiors)
2156}
2157
2158#[allow(clippy::too_many_arguments)]
2159fn check_track_value_residual(
2160 kind: ProofResidualKind,
2161 source: &Document,
2162 source_clip: &Clip,
2163 candidate_clip: &Clip,
2164 affected: &BTreeSet<BoneId>,
2165 t: f32,
2166 plan: &ScalePlan,
2167 tol: &ScaleTolerancePolicy,
2168 proof: &mut ScaleProof,
2169) -> Result<(), ScaleError> {
2170 // Paired positionally, not by a `(bone, property)` lookup:
2171 // `validate_candidate_structure` already established that `source_clip`
2172 // and `candidate_clip` have the same track count and each pair agrees on
2173 // `(bone, property)`, so a positional pairing cannot silently match the
2174 // wrong duplicate the way a `find` could.
2175 for (track, candidate_track) in source_clip.tracks.iter().zip(candidate_clip.tracks.iter()) {
2176 if track.property != Property::Translation || !affected.contains(&track.bone) {
2177 continue;
2178 }
2179 let multiplier = translation_multiplier(source, track.bone, affected, plan);
2180 let TrackSample::Vec3(before) = sample_track(track, t) else {
2181 return Err(ScaleError::MissingProofEvidence {
2182 kind,
2183 detail: "source_sample_not_vec3",
2184 });
2185 };
2186 let TrackSample::Vec3(after) = sample_track(candidate_track, t) else {
2187 return Err(ScaleError::MissingProofEvidence {
2188 kind,
2189 detail: "candidate_sample_not_vec3",
2190 });
2191 };
2192 let expected = before.as_dvec3() * multiplier;
2193 let actual = after.as_dvec3();
2194 let residual = (actual - expected).length();
2195 check_and_track(
2196 kind,
2197 residual,
2198 expected.length(),
2199 actual.length(),
2200 tol,
2201 proof,
2202 )?;
2203 }
2204 Ok(())
2205}
2206
2207/// Sampled world-space trajectory residual for one already-derived pose pair
2208/// (see [`sample_time_obligations`], which owns the single [`world_at_time`]
2209/// evaluation these matrices come from).
2210fn check_trajectory_residual_at(
2211 source_worlds: &WorldPose,
2212 candidate_worlds: &WorldPose,
2213 affected_nodes: &[BoneId],
2214 plan: &ScalePlan,
2215 tol: &ScaleTolerancePolicy,
2216 proof: &mut ScaleProof,
2217) -> Result<(), ScaleError> {
2218 for &node in affected_nodes {
2219 let before = source_worlds.bone(node)?.matrix;
2220 let after_pose = candidate_worlds.bone(node)?;
2221 let after = after_pose.matrix;
2222 let after_chain = after_pose.translation_rounding_magnitude;
2223 let (translation_residual, before_mag, after_mag) = rest_node_residual(
2224 before,
2225 after,
2226 plan.is_whole_document(),
2227 plan.common_factor(),
2228 );
2229 // The same magnitude the unanimated `RestTranslation` comparison
2230 // takes — the *candidate's* sampled parent chain, read off the sampled
2231 // pose this residual was composed from rather than the rest pose: the
2232 // two obligations differ only in which locals the chain ran on, and
2233 // the argument for reading the candidate side alone is the one stated
2234 // there.
2235 //
2236 // Reaching this term at all needs a rig whose *sampled* parent chain
2237 // cancels, which a clip over a rest pose that already cancels is the
2238 // simplest way to build:
2239 // `a_sampled_pose_whose_parent_chain_cancels_still_proves_its_trajectory`
2240 // and `the_trajectory_v6_floor_is_an_adjacent_f32_transition`
2241 // are those fixtures, and
2242 // `a_shrinking_conversion_holds_trajectory_to_the_candidate_s_own_chain`
2243 // is the one that separates the candidate's chain from the source's.
2244 // Without such a rig the only comparison this obligation makes has
2245 // `chain = 0` on both sides, and every mutation of the term is a no-op
2246 // on a quantity that is arithmetically absent.
2247 check_and_track_f32_rounded(
2248 ProofResidualKind::Trajectory,
2249 translation_residual,
2250 before_mag,
2251 after_mag,
2252 after_chain,
2253 tol,
2254 proof,
2255 )?;
2256 }
2257 Ok(())
2258}
2259
2260/// Sample `clip` at `t` and compose parent-before-child world matrices,
2261/// validating every input before it is indexed or accumulated: an
2262/// out-of-range track bone rejects rather than being skipped, a non-finite
2263/// sampled value or accumulated matrix rejects, and a parent index that is
2264/// not strictly earlier than its child rejects — the same structural
2265/// invariant [`crate::model::world_rest_matrices`]
2266/// enforces for the unanimated rest pose.
2267pub(super) fn world_at_time(
2268 skeleton: &Skeleton,
2269 clip: &Clip,
2270 t: f32,
2271) -> Result<WorldPose, ScaleError> {
2272 let bone_count = skeleton.bones.len();
2273 let mut locals = vec![Transform::IDENTITY; bone_count];
2274 for (index, bone) in skeleton.bones.iter().enumerate() {
2275 locals[index] = bone.rest;
2276 }
2277 for track in &clip.tracks {
2278 if track.bone >= bone_count {
2279 return Err(ScaleError::BoneIndexOutOfRange { index: track.bone });
2280 }
2281 match sample_track(track, t) {
2282 TrackSample::Vec3(value) => {
2283 if !value.is_finite() {
2284 return Err(ScaleError::NonFiniteTransform { node: track.bone });
2285 }
2286 match track.property {
2287 Property::Translation => locals[track.bone].translation = value,
2288 Property::Scale => locals[track.bone].scale = value,
2289 Property::Rotation => {}
2290 }
2291 }
2292 TrackSample::Quat(value) => {
2293 if !value.is_finite() {
2294 return Err(ScaleError::NonFiniteTransform { node: track.bone });
2295 }
2296 locals[track.bone].rotation = value;
2297 }
2298 }
2299 }
2300 let mut bones: Vec<WorldBonePose> = Vec::with_capacity(bone_count);
2301 for (index, bone) in skeleton.bones.iter().enumerate() {
2302 let local = locals[index].to_mat4();
2303 if !mat4_is_finite(local) {
2304 return Err(ScaleError::NonFiniteTransform { node: index });
2305 }
2306 let pose = match bone.parent {
2307 Some(parent) if parent < index => {
2308 // Accumulated in the same walk the composition runs in, so
2309 // the chain costs one `Mat4 * Vec4` per bone rather than a
2310 // second pass that would have to recompose every local.
2311 let parent_pose = bones[parent];
2312 let matrix = parent_pose.matrix * local;
2313 WorldBonePose {
2314 matrix,
2315 translation_rounding_magnitude: child_translation_rounding_magnitude(
2316 parent_pose,
2317 local,
2318 ),
2319 }
2320 }
2321 Some(parent) => {
2322 return Err(ScaleError::InvalidParent {
2323 node: index,
2324 parent,
2325 });
2326 }
2327 None => WorldBonePose {
2328 matrix: local,
2329 translation_rounding_magnitude: 0.0,
2330 },
2331 };
2332 if !mat4_is_finite(pose.matrix) {
2333 return Err(ScaleError::NonFiniteTransform { node: index });
2334 }
2335 bones.push(pose);
2336 }
2337 Ok(WorldPose { bones })
2338}
2339
2340/// `abs(planned - proved) / max(abs(planned), abs(proved))` — the divergence
2341/// between the two observed-factor witnesses, on the same comparison base
2342/// [`ScaleTolerancePolicy::relative`] uses.
2343///
2344/// Sharing that base is what makes the reported number commensurable with
2345/// [`ScaleTolerancePolicy::observed_factor_divergence_ceiling`], which is a
2346/// sum of two bands stated on it. The one division this spelling costs is
2347/// what a predicate would not pay, so the two are not bit-identical near the
2348/// ceiling; nothing here compares against it, so nothing depends on that.
2349///
2350/// No floor on the base, for [`ScaleTolerancePolicy::relative`]'s reasons,
2351/// and none is needed: `planned` is strictly positive under both operations —
2352/// a declared factor [`super::plan_scale`] range-checked, or an observed one
2353/// [`super::planning::classify_affine`] proved non-singular — so the base is at least
2354/// `planned` and never zero. `proved` carries no such guarantee: it is read
2355/// from whichever `source` [`prove_scale`] was handed, and a degenerate one
2356/// measuring exactly zero there reports a divergence of one rather than a
2357/// division by zero.
2358fn relative_divergence(planned: f64, proved: f64) -> f64 {
2359 (planned - proved).abs() / planned.abs().max(proved.abs())
2360}
2361
2362/// Re-derive this operation's observed factor from `source`, without reading
2363/// [`ScalePlan::observed_factor`].
2364///
2365/// For [`ScaleOperation::RestBindUniformScale`] the scaled root is resolved
2366/// the same way `planning::plan_rest_bind` resolves it — by `source_root_node_index`
2367/// through `source`'s own source-node projection — and its factor is then
2368/// measured from the *normalized* skeleton's rest-world matrix rather than
2369/// from the raw projection planning classified. That makes this a genuinely
2370/// second witness: it reads different stored data, through a different
2371/// composition path, and it is computed from whichever `source` this call was
2372/// handed, which [`prove_scale`] does not require to be the document the plan
2373/// came from.
2374///
2375/// # The scaled root is the minimum [`BoneId`] in the closure
2376///
2377/// §D.6 defines the observed factor *at the scaled root*, and "the lowest
2378/// affected bone id" is the reading a source-node-space walk and a
2379/// `BoneId`-space walk could otherwise land on separately. Once
2380/// [`crate::model::validate_document_shape`] holds they are the same node, and no
2381/// document can distinguish them:
2382///
2383/// - every insertion [`super::validation::rest_bind_affected_closure`] makes is the root itself,
2384/// a node on the ancestor path from a joint *up to* the root, or a BFS
2385/// descendant of something already in the set — so the closure lies inside
2386/// `subtree(root)` in source-node space;
2387/// - chain agreement is a parent-preserving injection, so it carries
2388/// `subtree(root)` onto `subtree(bone(root))` in `BoneId` space;
2389/// - [`crate::model::world_rest_matrices`] refuses a skeleton in which a parent's id is not
2390/// strictly less than its child's, so every member of `subtree(b)` has an
2391/// id at least `b`.
2392///
2393/// The scaled root is therefore the strict minimum id in the closure, and
2394/// reading either one reports the same number. That is a consequence of the
2395/// agreement precondition rather than of this function: without it the two
2396/// readings genuinely differ, and the difference is a false proof rather than
2397/// a naming quibble.
2398///
2399/// It is deliberately *not* a re-run of [`super::planning::classify_affine`]. Re-classifying
2400/// here would add a fresh rejection path — a proof source outside the
2401/// supported affine class would fail with a domain error rather than the
2402/// residual that actually matters — for no gain: whether the source is in the
2403/// class is a planning question, already answered, and the quantity wanted
2404/// here is only the factor.
2405///
2406/// # Errors
2407///
2408/// [`ScaleError::MissingProofEvidence`] when the plan's scaled root has no
2409/// projection in `source` to measure, and [`ScaleError::BoneIndexOutOfRange`]
2410/// when it projects to a bone `source` does not have. Neither is silently
2411/// reported as a zero factor.
2412pub(super) fn observed_factor_from_source(
2413 source: &Document,
2414 source_worlds: &WorldPose,
2415 plan: &ScalePlan,
2416) -> Result<f64, ScaleError> {
2417 let ScaleOperation::RestBindUniformScale {
2418 source_root_node_index,
2419 ..
2420 } = plan.operation()
2421 else {
2422 // Whole-document conversion declares its factor rather than observing
2423 // it; see [`ScalePlan::observed_factor`].
2424 return Ok(plan.common_factor());
2425 };
2426 let bone = source_node_index_map(source)
2427 .get(&source_root_node_index)
2428 .and_then(|asset| asset.bone)
2429 .ok_or(ScaleError::MissingProofEvidence {
2430 kind: ProofResidualKind::ObservedFactor,
2431 detail: "scaled_root_not_projected",
2432 })?;
2433 let world = source_worlds.bone(bone)?.matrix;
2434 Ok(average_affine_axis_length(affine_axis_lengths(
2435 Mat3::from_mat4(world),
2436 )))
2437}
2438
2439/// Prove that inverse-bind evidence *outside* the affected closure came
2440/// through unchanged.
2441///
2442/// [`check_skin_and_bounds`] skips an instance with no joint in the affected
2443/// closure entirely, and nothing else looked at one either — so a candidate
2444/// that rewrote an unrelated skin's `skin_ibms` proved `Ok`. Reachable
2445/// through the public API, since [`ScaleCandidate::from_document`] and
2446/// [`prove_scale`] do not require their two documents to be the same one.
2447///
2448/// Three effective cases, kept distinct on purpose:
2449///
2450/// - both sides resolve a bind for the slot — compare those effective binds;
2451/// - exactly one side resolves one — [`ScaleError::MissingProofEvidence`];
2452/// - neither side resolves one — nothing to compare, and nothing is claimed.
2453///
2454/// The third case keeps a genuinely evidence-free unrelated skin out of scope
2455/// rather than refusing a document the operation does not touch. It applies
2456/// only when both [`instance_bind`] calls return
2457/// [`ScaleError::MissingInverseBind`]. A complete attached source skin whose
2458/// accessor status is `Absent` instead resolves the format-defined identity,
2459/// so explicit and defaulted identity representations are compared as the
2460/// same effective bind.
2461///
2462/// Scope, stated exactly: this compares the bind each side resolves *for a
2463/// slot*, in the module's own precedence order — the instance array first,
2464/// then the bone convenience value. A [`crate::model::Bone::inverse_bind`]
2465/// that is shadowed by a non-empty `skin_ibms` is not authority for any slot
2466/// and is not compared here, and neither is
2467/// [`crate::model::SourceSkinAsset::inverse_bind_accessor`], which is
2468/// read-side evidence about the input accessor rather than a bind either
2469/// planning or proof consumes.
2470///
2471/// The skip is `any`, not `all`, and that is load-bearing rather than
2472/// idiomatic. An instance with *some* joint in the affected closure belongs to
2473/// [`check_skin_and_bounds`], which checks `W * B` on both sides and expects
2474/// exactly the rewrite the reference rest/bind writer performs on those
2475/// slots. Holding such an instance to "binds unchanged" as well rejects this
2476/// module's own output — pinned by
2477/// `a_partially_affected_skin_stays_with_the_skin_obligation_that_owns_it`.
2478///
2479/// Unconditional, like the per-element comparisons in
2480/// [`check_candidate_values`], though for its own reason: it is a structural
2481/// claim about payloads the plan declares *unaffected*, so there is no
2482/// obligation flag that could switch it off without also making the plan's
2483/// "unaffected" claim unfalsifiable. Base `POSITION` is unconditional on the
2484/// other ground — a whole-document plan *does* rewrite it, and its only other
2485/// witness reports zero for a document with no skinned instance.
2486fn check_unaffected_instance_binds(
2487 source: &Document,
2488 candidate: &Document,
2489 affected: &BTreeSet<BoneId>,
2490 tol: &ScaleTolerancePolicy,
2491 proof: &mut ScaleProof,
2492) -> Result<(), ScaleError> {
2493 // Zipped rather than indexed: `validate_candidate_structure` has already
2494 // proved the two instance lists have equal length and pairwise equal
2495 // `skin_joints`, so pairing them positionally needs no fallible lookup
2496 // and cannot silently drop a trailing instance.
2497 for (instance, candidate_instance) in source
2498 .assets
2499 .instances
2500 .iter()
2501 .zip(candidate.assets.instances.iter())
2502 {
2503 if instance
2504 .skin_joints
2505 .iter()
2506 .any(|joint| affected.contains(joint))
2507 {
2508 continue;
2509 }
2510 for (slot, &joint) in instance.skin_joints.iter().enumerate() {
2511 let before = instance_bind(source, instance, slot, joint);
2512 let after = instance_bind(candidate, candidate_instance, slot, joint);
2513 let (before, after) = match (before, after) {
2514 (Ok(before), Ok(after)) => (before, after),
2515 (
2516 Err(ScaleError::MissingInverseBind { .. }),
2517 Err(ScaleError::MissingInverseBind { .. }),
2518 ) => continue,
2519 (Ok(_), Err(ScaleError::MissingInverseBind { .. })) => {
2520 return Err(ScaleError::MissingProofEvidence {
2521 kind: ProofResidualKind::UnaffectedInverseBind,
2522 detail: "candidate_slot_bind_missing",
2523 });
2524 }
2525 (Err(ScaleError::MissingInverseBind { .. }), Ok(_)) => {
2526 return Err(ScaleError::MissingProofEvidence {
2527 kind: ProofResidualKind::UnaffectedInverseBind,
2528 detail: "source_slot_bind_missing",
2529 });
2530 }
2531 (Err(error), _) | (_, Err(error)) => return Err(error),
2532 };
2533 // Any slot reaching this function is outside a rest/bind closure
2534 // and therefore unchanged. A valid whole-document plan covers
2535 // every current bone; `validate_plan_document_inventory` rejects
2536 // stale replay before an added bone could reach this walk.
2537 let expected = before;
2538 let residual = matrix_residual(expected, after);
2539 // Both sides are effective matrices, so the magnitude the
2540 // comparison rounded against *is* the magnitude being compared:
2541 // `scale_translation_only` scales a column, it does not cancel
2542 // two terms the way composing `W * B` does, and a rotation
2543 // cannot make one of these entries small while its error stays
2544 // large. The rounding term is passed for the same base the
2545 // relative band already uses, which is what makes it inert here
2546 // — measured at `0` ulps across the whole rotation sweep. It is
2547 // stated rather than omitted so the policy quantity means one
2548 // thing across every obligation that compares `f32` matrices.
2549 let magnitude = matrix_magnitude(expected).max(matrix_magnitude(after));
2550 check_and_track_f32_rounded(
2551 ProofResidualKind::UnaffectedInverseBind,
2552 residual,
2553 matrix_magnitude(expected),
2554 matrix_magnitude(after),
2555 magnitude,
2556 tol,
2557 proof,
2558 )?;
2559 }
2560 }
2561 Ok(())
2562}
2563
2564/// The skin-equation and skinned-bounds obligations, evaluated in **one**
2565/// walk over the affected skinned instances.
2566///
2567/// Both obligations need the same three things per instance: the instance's
2568/// joint world matrices, its resolved inverse binds, and — for bounds — its
2569/// vertices. Splitting them across two entry points meant resolving the binds
2570/// twice and, worse, walking every vertex twice for bounds alone (once for
2571/// the source document, once for the candidate). This walks each vertex once
2572/// and skins it through both sides.
2573///
2574/// [`validate_candidate_structure`] has already established that paired
2575/// instances agree on `mesh` and `skin_joints` and that paired meshes agree
2576/// on primitive and vertex counts, so the two sides are known to have the
2577/// same slots and the same vertices to walk.
2578#[allow(clippy::too_many_arguments)]
2579fn check_skin_and_bounds(
2580 source: &Document,
2581 candidate: &Document,
2582 source_worlds: &WorldPose,
2583 candidate_worlds: &WorldPose,
2584 affected_skin_instances: &[usize],
2585 plan: &ScalePlan,
2586 tol: &ScaleTolerancePolicy,
2587 proof: &mut ScaleProof,
2588) -> Result<(), ScaleError> {
2589 if !plan.has_skin_and_bounds() {
2590 return Ok(());
2591 }
2592
2593 let mut source_bounds = BoundsAccumulator::default();
2594 let mut candidate_bounds = BoundsAccumulator::default();
2595
2596 // The factor the source side is rebased by before it is compared, and so
2597 // the factor its *rounding* is rebased by too. Both obligations below take
2598 // their comparison base as `candidate.max(q * source)` for this reason —
2599 // see the note at the skin-matrix call. `1.0` for rest/bind, where the two
2600 // documents state the same world in the same units and the rebasing is a
2601 // no-op.
2602 let q = if plan.is_whole_document() {
2603 plan.common_factor()
2604 } else {
2605 1.0
2606 };
2607
2608 for &instance_index in affected_skin_instances {
2609 let instance = &source.assets.instances[instance_index];
2610 let candidate_instance = candidate.assets.instances.get(instance_index).ok_or(
2611 ScaleError::MissingProofEvidence {
2612 kind: ProofResidualKind::SkinMatrix,
2613 detail: "candidate_instance_missing",
2614 },
2615 )?;
2616
2617 // Resolved once and reused by both obligations.
2618 let mut source_slots = Vec::with_capacity(instance.skin_joints.len());
2619 let mut candidate_slots = Vec::with_capacity(instance.skin_joints.len());
2620 for (slot, &joint) in instance.skin_joints.iter().enumerate() {
2621 let before_pose = source_worlds.bone(joint)?;
2622 let after_pose = candidate_worlds.bone(joint)?;
2623 let before_world = before_pose.matrix;
2624 let before_chain = before_pose.translation_rounding_magnitude;
2625 let after_world = after_pose.matrix;
2626 let after_chain = after_pose.translation_rounding_magnitude;
2627 let before_ibm = instance_bind(source, instance, slot, joint)?;
2628 let after_ibm = instance_bind(candidate, candidate_instance, slot, joint)?;
2629 source_slots.push(SkinSlot::compose(before_world, before_ibm, before_chain));
2630 candidate_slots.push(SkinSlot::compose(after_world, after_ibm, after_chain));
2631 }
2632
2633 for (before, after) in source_slots.iter().zip(candidate_slots.iter()) {
2634 // Whole-document conversion scales every affine's translation
2635 // by the declared factor while leaving its linear part
2636 // unchanged (the same `U M U^-1` conjugation as any other
2637 // retained matrix); rest/bind reparameterization analytically
2638 // preserves the skin equation exactly.
2639 let expected = if plan.is_whole_document() {
2640 scale_translation_only(before.matrix, plan.common_factor() as f32)
2641 } else {
2642 before.matrix
2643 };
2644 let residual = matrix_residual(expected, after.matrix);
2645 // The candidate's own composition magnitude, and the source's
2646 // *rebased by the factor*. The residual is `|after - q *
2647 // before|`, so the source operand enters the comparison
2648 // multiplied by `q` and its rounding is multiplied by `q` with
2649 // it: a source slot accurate to `k` ulps of its own magnitude
2650 // contributes `q * k` ulps of that magnitude here. A base that
2651 // reads the source side unrebased — which `max(before, after)`
2652 // did — therefore states the source's error in the wrong units
2653 // by a factor of `q`.
2654 //
2655 // Under a *shrinking* conversion that is the whole defect: the
2656 // unrebased source magnitude is `1/q` times too large, so the
2657 // band freezes at the source rig's size while the candidate it
2658 // is spent on keeps shrinking. Measured over the sweep
2659 // populations below the recovered discriminating power is the
2660 // factor exactly — `100x` at `0.01`, `10_000x` at `1e-4`.
2661 //
2662 // Unlike the parent-chain case this does **not** reduce to the
2663 // candidate's magnitude alone, because `q * before` is not
2664 // bounded by `after`: the two magnitudes are a factor apart
2665 // only in the terms that carry a translation, and both retain
2666 // an unscaled `O(1)` floor from the composition's linear block
2667 // and the homogeneous row. Where that floor dominates the
2668 // source — small joints carrying small geometry — `q * before`
2669 // exceeds `after` by up to the full factor under a growing
2670 // conversion.
2671 //
2672 // `q * before` is written anyway because it is the bound that
2673 // can be argued from the operands rather than measured from a
2674 // population: a source slot accurate to the count's own budget
2675 // contributes `q` times that budget here, whatever cancels.
2676 // `a_growing_conversion_provisions_a_rebased_source_magnitude`
2677 // pins the regime where this rebased source term exceeds the
2678 // candidate term by orders of magnitude.
2679 let magnitude = after.rounding_magnitude.max(q * before.rounding_magnitude);
2680 check_and_track_f32_rounded(
2681 ProofResidualKind::SkinMatrix,
2682 residual,
2683 matrix_magnitude(expected),
2684 matrix_magnitude(after.matrix),
2685 magnitude,
2686 tol,
2687 proof,
2688 )?;
2689 }
2690
2691 let mesh = source.assets.meshes.get(instance.mesh).ok_or(
2692 DocumentShapeError::MeshInstanceShape {
2693 instance_index,
2694 violation: MeshInstanceShapeViolation::MeshIndexOutOfRange,
2695 },
2696 )?;
2697 let candidate_mesh = candidate.assets.meshes.get(candidate_instance.mesh).ok_or(
2698 DocumentShapeError::MeshInstanceShape {
2699 instance_index,
2700 violation: MeshInstanceShapeViolation::MeshIndexOutOfRange,
2701 },
2702 )?;
2703 for (primitive_index, (primitive, candidate_primitive)) in mesh
2704 .primitives
2705 .iter()
2706 .zip(candidate_mesh.primitives.iter())
2707 .enumerate()
2708 {
2709 accumulate_skinned_bounds(
2710 instance_index,
2711 primitive_index,
2712 primitive,
2713 &source_slots,
2714 &mut source_bounds,
2715 )?;
2716 accumulate_skinned_bounds(
2717 instance_index,
2718 primitive_index,
2719 candidate_primitive,
2720 &candidate_slots,
2721 &mut candidate_bounds,
2722 )?;
2723 }
2724 }
2725
2726 let source_bounds_magnitude = source_bounds.rounding_magnitude();
2727 let candidate_bounds_magnitude = candidate_bounds.rounding_magnitude();
2728 let (before_min, before_max) =
2729 source_bounds
2730 .finish()
2731 .ok_or(ScaleError::MissingProofEvidence {
2732 kind: ProofResidualKind::Bounds,
2733 detail: "source_bounds_missing",
2734 })?;
2735 let (after_min, after_max) =
2736 candidate_bounds
2737 .finish()
2738 .ok_or(ScaleError::MissingProofEvidence {
2739 kind: ProofResidualKind::Bounds,
2740 detail: "candidate_bounds_missing",
2741 })?;
2742 // One magnitude for all six comparisons, never the corner a residual lands
2743 // on: that corner is not evidence about the arithmetic that produced it. A
2744 // per-axis extreme is contributed by whichever vertex happened to be
2745 // furthest along that axis, and three vertices at `(3000, .001, .002)`,
2746 // `(.001, 3000, .003)` and `(.002, .003, 3000)` build a corner of magnitude
2747 // `2.4e-3` out of vertices of magnitude `3000` — so a base read off the
2748 // corner would be a million times smaller than the rounding error the
2749 // corner carries.
2750 //
2751 // The candidate's magnitude against the source's *rebased by the factor*,
2752 // for the reason the skin-matrix call states in full: the comparison below
2753 // is `|a - q * b|`, so the source bound's rounding enters it multiplied by
2754 // `q`. `max(source, candidate)` stated that rounding in the source rig's
2755 // units and was loose by `1/q` under a shrinking conversion — `100x` at
2756 // `0.01` and `10_000x` at `1e-4`, both recovered here.
2757 let magnitude = candidate_bounds_magnitude.max(q * source_bounds_magnitude);
2758 for (before, after) in [(before_min, after_min), (before_max, after_max)] {
2759 let before = before.to_array();
2760 let after = after.to_array();
2761 for axis in 0..3 {
2762 let b = before[axis] as f64;
2763 let a = after[axis] as f64;
2764 let expected = b * q;
2765 let residual = (a - expected).abs();
2766 check_and_track_f32_rounded(
2767 ProofResidualKind::Bounds,
2768 residual,
2769 expected,
2770 a,
2771 magnitude,
2772 tol,
2773 proof,
2774 )?;
2775 }
2776 }
2777 Ok(())
2778}
2779
2780/// One skin slot's composed `W * B`, together with the magnitude that
2781/// composition rounded against.
2782///
2783/// The two travel together because a caller that has one without the other
2784/// cannot state a tolerance for anything derived from it: `matrix` is
2785/// near-identity for a bind-pose slot no matter how far from the origin the
2786/// joint sits, while `rounding_magnitude` is where the arithmetic actually
2787/// happened (see [`product_operand_magnitude`]).
2788#[derive(Debug, Clone, Copy)]
2789pub(super) struct SkinSlot {
2790 pub(super) matrix: Mat4,
2791 /// [`mat4_abs`] of [`Self::matrix`], for
2792 /// [`column_operand_magnitude`]'s per-vertex use.
2793 ///
2794 /// Held here rather than taken per vertex because it is constant across
2795 /// every vertex the slot influences and the loop that reads it is the
2796 /// hottest in this proof.
2797 pub(super) absolute: Mat4,
2798 pub(super) rounding_magnitude: f64,
2799}
2800
2801impl SkinSlot {
2802 /// Compose `world * inverse_bind`, carrying the larger of the two
2803 /// magnitudes the result's error can come from.
2804 ///
2805 /// `world_translation_rounding_magnitude` is the accumulated provenance
2806 /// for this slot's joint. The fixed chain and `W * B` stages retain the
2807 /// measured policy's maximum here: #337 changes the unbounded number of
2808 /// links *inside* the incoming chain, not this fixed two-stage envelope.
2809 /// Both terms remain load-bearing in the calibrated corpus; replacing the
2810 /// envelope with an analytic componentwise error propagation is a broader
2811 /// model, not a larger scalar sum hidden in this constructor.
2812 pub(super) fn compose(
2813 world: Mat4,
2814 inverse_bind: Mat4,
2815 world_translation_rounding_magnitude: f64,
2816 ) -> Self {
2817 let matrix = world * inverse_bind;
2818 Self {
2819 matrix,
2820 absolute: mat4_abs(matrix),
2821 rounding_magnitude: product_operand_magnitude(world, inverse_bind)
2822 .max(world_translation_rounding_magnitude),
2823 }
2824 }
2825}
2826
2827/// Running skinned-bounds extremes for one document side, and the largest
2828/// magnitude the `f32` arithmetic behind them ran on.
2829///
2830/// `touched` distinguishes "every relevant vertex was unweighted" from "the
2831/// bounds happen to be at the origin": the former has no bounds evidence at
2832/// all and must be reported as missing, not as a zero residual.
2833///
2834/// `rounding_magnitude` is what
2835/// [`ScaleTolerancePolicy::f32_rounding_ulps`] is counted in for
2836/// [`ProofResidualKind::Bounds`]. For each contributing influence, it is the
2837/// larger of the magnitude the `W * B * p` transform ran on and the slot's
2838/// [`SkinSlot::rounding_magnitude`]. The former is
2839/// [`column_operand_magnitude`] of the composed slot against `p` extended by
2840/// the homogeneous `1` — the *product* `abs(W * B) * abs(p)` and not either
2841/// factor alone. The latter carries the two earlier stages: the composition
2842/// that produced `W * B`, whose translation column may cancel large `W` and
2843/// `B` terms, and the parent chain that produced `W`, whose translation may
2844/// already contain cancellation.
2845///
2846/// The per-influence magnitudes are combined with the same binary64 weighted
2847/// average of the stored, non-negative binary32 weights as the skinned point.
2848/// A tiny influence therefore carries only its proportional arithmetic
2849/// provenance; taking a plain max would let an arbitrarily small weight on a
2850/// distant joint widen the whole bound tolerance. The blended point's own
2851/// per-axis magnitude is consequently already bounded by the weighted
2852/// transform operands and needs no separate L2 stage. See DESIGN.md Appendix
2853/// D §D.1.
2854pub(super) struct BoundsAccumulator {
2855 min: Vec3,
2856 max: Vec3,
2857 touched: bool,
2858 rounding_magnitude: f64,
2859}
2860
2861impl Default for BoundsAccumulator {
2862 fn default() -> Self {
2863 Self {
2864 min: Vec3::splat(f32::INFINITY),
2865 max: Vec3::splat(f32::NEG_INFINITY),
2866 touched: false,
2867 rounding_magnitude: 0.0,
2868 }
2869 }
2870}
2871
2872impl BoundsAccumulator {
2873 pub(super) fn finish(self) -> Option<(Vec3, Vec3)> {
2874 self.touched.then_some((self.min, self.max))
2875 }
2876
2877 pub(super) fn rounding_magnitude(&self) -> f64 {
2878 self.rounding_magnitude
2879 }
2880}
2881
2882/// Skin one primitive's vertices through `slots` (already-composed
2883/// `W_i * B_i` per skin slot) and fold them into `bounds`, rejecting rather
2884/// than skipping every malformed input along the way: a primitive whose
2885/// per-vertex `joints`/`weights` are not exactly parallel to `positions`, a
2886/// non-finite position or weight, a joint-influence slot outside the
2887/// instance's `skin_joints`, or a non-finite skinned result. A vertex whose
2888/// four weights are all zero is legitimately unweighted (not malformed) and
2889/// is excluded from bounds.
2890pub(super) fn accumulate_skinned_bounds(
2891 instance_index: usize,
2892 primitive_index: usize,
2893 primitive: &Primitive,
2894 slots: &[SkinSlot],
2895 bounds: &mut BoundsAccumulator,
2896) -> Result<(), ScaleError> {
2897 if primitive.joints.len() != primitive.positions.len()
2898 || primitive.weights.len() != primitive.positions.len()
2899 {
2900 return Err(ScaleError::InvalidSkinnedPrimitive {
2901 instance_index,
2902 primitive_index,
2903 reason: "joints_or_weights_length_mismatch",
2904 });
2905 }
2906 for (vertex, &position) in primitive.positions.iter().enumerate() {
2907 if !position.is_finite() {
2908 return Err(ScaleError::InvalidSkinnedPrimitive {
2909 instance_index,
2910 primitive_index,
2911 reason: "non_finite_position",
2912 });
2913 }
2914 let joints = primitive.joints[vertex];
2915 let weights = primitive.weights[vertex];
2916 // Accumulate both the weighted numerator and denominator in binary64,
2917 // then narrow the normalized point once. Binary32 multiply-then-divide
2918 // can lose a lone subnormal contribution or overflow a large finite
2919 // denominator. Precomputing binary32 coefficients is not sufficient:
2920 // their rounded sum can exceed one and overflow an otherwise finite
2921 // convex blend at `f32::MAX`.
2922 let mut weight_sum = 0.0f64;
2923 for weight in weights {
2924 if weight == 0.0 {
2925 continue;
2926 }
2927 if !weight.is_finite() {
2928 return Err(ScaleError::InvalidSkinnedPrimitive {
2929 instance_index,
2930 primitive_index,
2931 reason: "non_finite_weight",
2932 });
2933 }
2934 weight_sum += f64::from(weight);
2935 }
2936 if weight_sum == 0.0 {
2937 continue;
2938 }
2939
2940 let mut skinned_numerator = DVec3::ZERO;
2941 let mut weighted_magnitude = 0.0f64;
2942 for slot_index in 0..4 {
2943 let stored_weight = weights[slot_index];
2944 if stored_weight == 0.0 {
2945 continue;
2946 }
2947 let Some(slot) = slots.get(joints[slot_index] as usize) else {
2948 return Err(ScaleError::InvalidSkinnedPrimitive {
2949 instance_index,
2950 primitive_index,
2951 reason: "joint_influence_slot_out_of_range",
2952 });
2953 };
2954 let weight = f64::from(stored_weight);
2955 skinned_numerator += weight * slot.matrix.transform_point3(position).as_dvec3();
2956 // The magnitude `slot.matrix.transform_point3(position)` runs on,
2957 // which is `abs(W * B) * abs(p)` and not either factor alone.
2958 //
2959 // `abs(p)` alone — which is what this stage read before — names one
2960 // of the two. It is the right number only while `abs(W * B)` is
2961 // `1`, which is every rig whose slots are a pure rotation of the
2962 // bind pose, and that is the whole of what the fixtures below build:
2963 // `cancelling_blend_document` composes with `HALF_TURN_Z`, so its
2964 // stage was accidentally exact and no test could see the gap. Give
2965 // the composed slot a scale of `k` and the transform runs on
2966 // `k * abs(p)` while the base reads `abs(p)`, short by `k`; the
2967 // weighted sum over slots can then cancel the result to nothing
2968 // while every term still carries `k * abs(p)`'s ulp.
2969 // `two_slots_with_a_scaled_composition_cancel_a_vertex_and_still_prove_its_bounds`
2970 // is that rig, and it is refused outright — `observed: 1.53e-5`
2971 // against `tolerance: 8.63e-6` at `k = 16` — without this term.
2972 //
2973 // The homogeneous `1` is included because `transform_point3` sums
2974 // the translation column in with the rest, so that column's entries
2975 // are terms of the same dot product.
2976 let influence_magnitude = skin_influence_magnitude(slot, position);
2977 weighted_magnitude += weight * influence_magnitude;
2978 }
2979 let skinned = (skinned_numerator / weight_sum).as_vec3();
2980 if !skinned.is_finite() {
2981 // Overflow and `NaN` are different failures and are
2982 // reported as such: an overflowing skinned position is a
2983 // document whose geometry leaves the `f32` range this proof
2984 // computes in, while a `NaN` is a malformed or degenerate
2985 // input that survived every finiteness check above. No
2986 // magnitude domain is documented for the former, because the
2987 // boundary is not a property of any magnitude a document
2988 // could be checked against ahead of time:
2989 // `transform_point3` accumulates a dot product whose
2990 // intermediate terms depend on the rotation, so two rigs
2991 // whose skinned extents agree can disagree on whether they
2992 // compose finitely.
2993 return Err(ScaleError::InvalidSkinnedPrimitive {
2994 instance_index,
2995 primitive_index,
2996 reason: if skinned.is_nan() {
2997 "non_finite_result"
2998 } else {
2999 "skinned_magnitude_overflow"
3000 },
3001 });
3002 }
3003 bounds.min = bounds.min.min(skinned);
3004 bounds.max = bounds.max.max(skinned);
3005 let vertex_magnitude = weighted_magnitude / weight_sum;
3006 bounds.rounding_magnitude = bounds.rounding_magnitude.max(vertex_magnitude);
3007 bounds.touched = true;
3008 }
3009 Ok(())
3010}
3011
3012/// The per-axis arithmetic provenance one weighted skin influence carries
3013/// into a bound. The transform application and the already-composed slot can
3014/// each dominate by an unbounded ratio, so the influence retains the larger;
3015/// [`accumulate_skinned_bounds`] then combines influences with the same
3016/// binary64 weighted average as the skinned point.
3017pub(super) fn skin_influence_magnitude(slot: &SkinSlot, position: Vec3) -> f64 {
3018 column_operand_magnitude(slot.absolute, position.extend(1.0)).max(slot.rounding_magnitude)
3019}