Skip to main content

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}