Skip to main content

animsmith_core/scale/
planning.rs

1//! Private scale planning, compiled-ledger construction, and replay validation.
2//!
3//! This module owns operation planning and the numeric-value-free structural
4//! inventory a [`super::ScalePlan`] compiles. Candidate construction and proof
5//! remain separate so their numeric expectations stay independently derived.
6
7use super::validation::{
8    derive_rest_bind_plan_domain, source_node_index_map, source_skin_payload_shapes,
9    source_world_matrix, validate_scale_input, whole_document_source_topology,
10};
11use super::{
12    RestBindParams, ScaleBoneRestField, ScaleCompiledPlan, ScaleError, ScaleFieldDisposition,
13    ScaleFieldPlan, ScaleFieldTarget, ScaleLedger, ScaleOperation, ScalePayloadShapeRow, ScalePlan,
14    ScaleProjectedRole, ScaleProofObligation, ScaleRequest, ScaleRewriteRule, ScaleSourceNodeKind,
15    ScaleSourceRestField, ScaleSourceTopologyRow, ScaleTolerancePolicy, WholeDocumentParams,
16};
17use crate::model::{
18    AffineDomainViolation, BoneId, Document, Interpolation, PositiveUniformAffineTolerance,
19    Property, SourceNodeLocalRest, SourceSkeletonCoverage, classify_positive_uniform_affine,
20};
21use glam::Mat3;
22use std::collections::{BTreeMap, BTreeSet};
23
24/// Plan the caller-selected [`ScaleOperation`] against `request.document`.
25///
26/// Pure and fail-closed: this function only reads `request.document` and
27/// `request.capability`. It never returns a plan for a factor that was not
28/// declared or an affine domain outside the initial supported class.
29///
30/// # Errors
31///
32/// Returns a typed [`ScaleError`] for every unsupported factor, selector,
33/// capability gap, affine domain, closure incompleteness, factor mismatch,
34/// or invalid document shape.
35pub fn plan_scale(request: &ScaleRequest<'_>) -> Result<ScalePlan, ScaleError> {
36    if !request.capability.is_supported_for(request.operation) {
37        return Err(ScaleError::IncompleteCapability);
38    }
39    validate_scale_input(request.document)?;
40    match request.operation {
41        ScaleOperation::WholeDocumentLinearUnits { factor } => {
42            plan_whole_document(request.document, factor)
43        }
44        ScaleOperation::RestBindUniformScale {
45            source_skin_index,
46            source_root_node_index,
47            expected_factor,
48        } => plan_rest_bind(
49            request.document,
50            source_skin_index,
51            source_root_node_index,
52            expected_factor,
53        ),
54    }
55}
56
57/// Reject an `f64` factor whose `f32` image cannot carry it.
58///
59/// Every rewrite in this module narrows to `f32` at the model boundary
60/// (`Vec3`/`Mat4` are `f32`), so a factor that only exists in `f64` is not a
61/// conversion this operation can perform. Both failure modes are silent
62/// without this check: overflow to `±inf` produces a document full of
63/// `NaN`/`inf` that only proof catches, and underflow of a nonzero factor to
64/// `0.0f32` annihilates every length in the document while every proof
65/// residual stays exactly zero.
66///
67/// Checked at *plan* time, for both the declared factor and the reciprocal
68/// `1 / factor` the rest/bind basis correction derives from it, so no
69/// unrepresentable factor ever reaches a builder.
70pub(in crate::scale) fn check_factor_narrows(
71    declared: f64,
72    factor: f64,
73) -> Result<f32, ScaleError> {
74    let narrowed = factor as f32;
75    if !narrowed.is_finite() || (narrowed == 0.0 && factor != 0.0) {
76        return Err(ScaleError::FactorNotRepresentable {
77            declared,
78            factor,
79            narrowed,
80        });
81    }
82    Ok(narrowed)
83}
84
85fn field_disposition(active: bool, rule: ScaleRewriteRule) -> ScaleFieldDisposition {
86    if active {
87        ScaleFieldDisposition::Rewrite(rule)
88    } else {
89        ScaleFieldDisposition::PreserveExact
90    }
91}
92
93fn compile_payload_shapes(document: &Document) -> Vec<ScalePayloadShapeRow> {
94    let source_authoritative =
95        document.assets.source_skeleton.coverage == SourceSkeletonCoverage::Complete;
96    let mut rows = vec![ScalePayloadShapeRow::Document {
97        bone_count: document.skeleton.bones.len(),
98        source_node_count: if source_authoritative {
99            document.assets.source_skeleton.nodes.len()
100        } else {
101            0
102        },
103        source_coverage: document.assets.source_skeleton.coverage,
104        clip_count: document.clips.len(),
105        instance_count: document.assets.instances.len(),
106        mesh_count: document.assets.meshes.len(),
107    }];
108    rows.extend(
109        document
110            .skeleton
111            .bones
112            .iter()
113            .enumerate()
114            .map(|(bone, value)| ScalePayloadShapeRow::Bone {
115                bone,
116                parent: value.parent,
117            }),
118    );
119    rows.extend(source_skin_payload_shapes(document));
120    for (clip_index, clip) in document.clips.iter().enumerate() {
121        rows.push(ScalePayloadShapeRow::Clip {
122            clip_index,
123            track_count: clip.tracks.len(),
124        });
125        rows.extend(clip.tracks.iter().enumerate().map(|(track_index, track)| {
126            ScalePayloadShapeRow::Track {
127                clip_index,
128                track_index,
129                bone: track.bone,
130                property: track.property,
131                interpolation: track.interpolation,
132                key_count: track.times.len(),
133                value_count: track.values.len(),
134            }
135        }));
136    }
137    for (instance_index, instance) in document.assets.instances.iter().enumerate() {
138        rows.push(ScalePayloadShapeRow::Instance {
139            instance_index,
140            node: instance.node,
141            source_node_index: instance.source_node_index,
142            mesh: instance.mesh,
143            joint_count: instance.skin_joints.len(),
144            inverse_bind_count: instance.skin_ibms.len(),
145        });
146        rows.extend(
147            instance
148                .skin_joints
149                .iter()
150                .enumerate()
151                .map(|(slot, &joint)| ScalePayloadShapeRow::InstanceJoint {
152                    instance_index,
153                    slot,
154                    joint,
155                }),
156        );
157    }
158    for (mesh_index, mesh) in document.assets.meshes.iter().enumerate() {
159        rows.push(ScalePayloadShapeRow::Mesh {
160            mesh_index,
161            source_mesh_index: mesh.source_mesh_index,
162            primitive_count: mesh.primitives.len(),
163        });
164        rows.extend(
165            mesh.primitives
166                .iter()
167                .enumerate()
168                .map(
169                    |(primitive_index, primitive)| ScalePayloadShapeRow::Primitive {
170                        mesh_index,
171                        primitive_index,
172                        position_count: primitive.positions.len(),
173                        normal_count: primitive.normals.len(),
174                        joint_count: primitive.joints.len(),
175                        weight_count: primitive.weights.len(),
176                    },
177                ),
178        );
179    }
180    rows
181}
182
183fn compile_scale_ledger(
184    document: &Document,
185    operation: ScaleOperation,
186    affected_nodes: &[BoneId],
187    transform_only_attachments: &[BoneId],
188    topology: &[ScaleSourceTopologyRow],
189) -> ScaleLedger {
190    let affected: BTreeSet<_> = affected_nodes.iter().copied().collect();
191    let factor = match operation {
192        ScaleOperation::WholeDocumentLinearUnits { factor } => factor,
193        ScaleOperation::RestBindUniformScale {
194            expected_factor, ..
195        } => expected_factor,
196    };
197    let factor_changes = factor != 1.0;
198    let whole_document = matches!(operation, ScaleOperation::WholeDocumentLinearUnits { .. });
199    let mut fields = Vec::new();
200
201    for (bone, value) in document.skeleton.bones.iter().enumerate() {
202        let in_domain = affected.contains(&bone);
203        let parent_in_domain = value
204            .parent
205            .is_some_and(|parent| affected.contains(&parent));
206        let translation = if whole_document {
207            field_disposition(factor_changes, ScaleRewriteRule::WholeDocumentLength)
208        } else {
209            field_disposition(
210                factor_changes && in_domain && parent_in_domain,
211                ScaleRewriteRule::RestBindParentBasis,
212            )
213        };
214        let scale = if whole_document {
215            ScaleFieldDisposition::PreserveExact
216        } else {
217            field_disposition(
218                factor_changes && in_domain && !parent_in_domain,
219                ScaleRewriteRule::RestBindLocalScale,
220            )
221        };
222        for (field, disposition) in [
223            (ScaleBoneRestField::Translation, translation),
224            (
225                ScaleBoneRestField::Rotation,
226                ScaleFieldDisposition::PreserveExact,
227            ),
228            (ScaleBoneRestField::Scale, scale),
229        ] {
230            fields.push(ScaleFieldPlan {
231                target: ScaleFieldTarget::BoneRest { bone, field },
232                disposition,
233                element_count: 1,
234            });
235        }
236        if value.inverse_bind.is_some() {
237            fields.push(ScaleFieldPlan {
238                target: ScaleFieldTarget::BoneInverseBind { bone },
239                disposition: if whole_document {
240                    field_disposition(factor_changes, ScaleRewriteRule::WholeDocumentLength)
241                } else {
242                    field_disposition(
243                        factor_changes && in_domain,
244                        ScaleRewriteRule::RestBindNodeBasis,
245                    )
246                },
247                element_count: 1,
248            });
249        }
250    }
251
252    let source_nodes = source_node_index_map(document);
253    for topology_row in topology {
254        let node = source_nodes
255            .get(&topology_row.source_node_index)
256            .expect("validated topology row has a source node");
257        let (role, connector_tail) = match topology_row.kind {
258            ScaleSourceNodeKind::Projected {
259                role,
260                incoming_connector_tail,
261                ..
262            } => (Some(role), incoming_connector_tail),
263            ScaleSourceNodeKind::Connector | ScaleSourceNodeKind::OutsideDomain { .. } => {
264                (None, None)
265            }
266        };
267        let parent_rewrite = factor_changes
268            && (whole_document || role.is_some_and(|role| role != ScaleProjectedRole::Root));
269        let local_rewrite = factor_changes && role == Some(ScaleProjectedRole::Root);
270        let source_rule = if whole_document {
271            ScaleRewriteRule::WholeDocumentLength
272        } else {
273            ScaleRewriteRule::RestBindSourceLocal { connector_tail }
274        };
275        let push = |fields: &mut Vec<ScaleFieldPlan>, field: ScaleSourceRestField, active: bool| {
276            fields.push(ScaleFieldPlan {
277                target: ScaleFieldTarget::SourceNodeRest {
278                    source_node_index: node.source_node_index,
279                    field,
280                },
281                disposition: field_disposition(active, source_rule),
282                element_count: 1,
283            });
284        };
285        match node.local_rest {
286            SourceNodeLocalRest::Trs { .. } => {
287                push(
288                    &mut fields,
289                    ScaleSourceRestField::Translation,
290                    parent_rewrite,
291                );
292                push(&mut fields, ScaleSourceRestField::Rotation, false);
293                push(&mut fields, ScaleSourceRestField::Scale, local_rewrite);
294            }
295            SourceNodeLocalRest::Matrix(_) => {
296                push(
297                    &mut fields,
298                    ScaleSourceRestField::MatrixLinear,
299                    local_rewrite,
300                );
301                push(
302                    &mut fields,
303                    ScaleSourceRestField::MatrixTranslation,
304                    parent_rewrite,
305                );
306                push(&mut fields, ScaleSourceRestField::MatrixHomogeneous, false);
307            }
308        }
309    }
310
311    let mut has_tracks = false;
312    for (clip_index, clip) in document.clips.iter().enumerate() {
313        for (track_index, track) in clip.tracks.iter().enumerate() {
314            has_tracks = true;
315            let parent_in_domain = document
316                .skeleton
317                .bones
318                .get(track.bone)
319                .and_then(|bone| bone.parent)
320                .is_some_and(|parent| affected.contains(&parent));
321            let disposition = match track.property {
322                Property::Translation if whole_document => {
323                    field_disposition(factor_changes, ScaleRewriteRule::WholeDocumentLength)
324                }
325                Property::Translation if affected.contains(&track.bone) => field_disposition(
326                    factor_changes && parent_in_domain,
327                    ScaleRewriteRule::RestBindParentBasis,
328                ),
329                Property::Scale if !whole_document && affected.contains(&track.bone) => {
330                    field_disposition(
331                        factor_changes && !parent_in_domain,
332                        ScaleRewriteRule::RestBindLocalScale,
333                    )
334                }
335                _ => ScaleFieldDisposition::PreserveExact,
336            };
337            fields.push(ScaleFieldPlan {
338                target: ScaleFieldTarget::AnimationValues {
339                    clip_index,
340                    track_index,
341                    bone: track.bone,
342                    property: track.property,
343                },
344                disposition,
345                element_count: track.values.len(),
346            });
347        }
348    }
349
350    let mut has_affected_slots = false;
351    let mut has_unaffected_slots = false;
352    let mut has_skinned_instances = false;
353    for (instance_index, instance) in document.assets.instances.iter().enumerate() {
354        let instance_affected = instance
355            .skin_joints
356            .iter()
357            .any(|joint| affected.contains(joint));
358        if instance_affected {
359            has_skinned_instances = true;
360        }
361        let slots: Vec<_> = if whole_document {
362            instance
363                .skin_ibms
364                .iter()
365                .enumerate()
366                .filter_map(|(slot, _)| {
367                    instance
368                        .skin_joints
369                        .get(slot)
370                        .copied()
371                        .map(|joint| (slot, joint))
372                })
373                .collect()
374        } else {
375            instance.skin_joints.iter().copied().enumerate().collect()
376        };
377        for (slot, joint) in slots {
378            if whole_document || instance_affected {
379                has_affected_slots = true;
380            } else {
381                has_unaffected_slots = true;
382            }
383            fields.push(ScaleFieldPlan {
384                target: ScaleFieldTarget::InstanceInverseBind {
385                    instance_index,
386                    slot,
387                    joint,
388                },
389                disposition: if whole_document {
390                    field_disposition(factor_changes, ScaleRewriteRule::WholeDocumentLength)
391                } else if instance_affected {
392                    ScaleFieldDisposition::Rewrite(ScaleRewriteRule::RestBindNodeBasis)
393                } else {
394                    ScaleFieldDisposition::PreserveExact
395                },
396                element_count: 1,
397            });
398        }
399    }
400
401    let mut has_primitives = false;
402    for (mesh_index, mesh) in document.assets.meshes.iter().enumerate() {
403        for (primitive_index, primitive) in mesh.primitives.iter().enumerate() {
404            has_primitives = true;
405            fields.push(ScaleFieldPlan {
406                target: ScaleFieldTarget::MeshPositions {
407                    mesh_index,
408                    primitive_index,
409                },
410                disposition: if whole_document {
411                    field_disposition(factor_changes, ScaleRewriteRule::WholeDocumentLength)
412                } else {
413                    ScaleFieldDisposition::PreserveExact
414                },
415                element_count: primitive.positions.len(),
416            });
417            fields.push(ScaleFieldPlan {
418                target: ScaleFieldTarget::MeshNormals {
419                    mesh_index,
420                    primitive_index,
421                },
422                disposition: ScaleFieldDisposition::PreserveExact,
423                element_count: primitive.normals.len(),
424            });
425        }
426    }
427
428    let has_unaffected_nodes = affected.len() != document.skeleton.bones.len();
429    let sampled = sampled_evidence(document, &affected);
430    let has_connectors = topology
431        .iter()
432        .any(|row| matches!(row.kind, ScaleSourceNodeKind::Connector));
433    let mut obligations = vec![
434        ScaleProofObligation::ExactTopology,
435        ScaleProofObligation::ExactPayloadIdentity,
436    ];
437    if has_unaffected_nodes {
438        obligations.push(ScaleProofObligation::ExactUnchangedWorldRest);
439    }
440    if !affected_nodes.is_empty() {
441        obligations.push(if whole_document {
442            ScaleProofObligation::RestWorld
443        } else {
444            ScaleProofObligation::RestWorldAndUnitScale
445        });
446    }
447    if !transform_only_attachments.is_empty() {
448        obligations.push(ScaleProofObligation::TransformOnlyAffine);
449    }
450    if has_tracks {
451        obligations.push(ScaleProofObligation::TrackValues);
452    }
453    if has_primitives {
454        obligations.push(ScaleProofObligation::MeshPositions);
455    }
456    if sampled.key_translations {
457        obligations.push(ScaleProofObligation::KeyTranslations);
458    }
459    if sampled.cubic_interiors {
460        obligations.push(ScaleProofObligation::CubicInteriors);
461    }
462    if sampled.sample_times {
463        obligations.push(ScaleProofObligation::Trajectories);
464    }
465    if has_skinned_instances {
466        obligations.push(ScaleProofObligation::SkinAndBounds);
467    }
468    if has_affected_slots {
469        obligations.push(ScaleProofObligation::AffectedInverseBinds);
470    }
471    if has_unaffected_slots {
472        obligations.push(ScaleProofObligation::UnaffectedInverseBinds);
473    }
474    if has_connectors {
475        obligations.push(ScaleProofObligation::ExactConnectorProjection);
476    }
477    ScaleLedger {
478        field_rows: fields,
479        payload_shapes: compile_payload_shapes(document),
480        obligations,
481    }
482}
483
484fn plan_whole_document(document: &Document, factor: f64) -> Result<ScalePlan, ScaleError> {
485    if !factor.is_finite() || factor <= 0.0 {
486        return Err(ScaleError::InvalidFactor { factor });
487    }
488    check_factor_narrows(factor, factor)?;
489    let affected_nodes: Vec<BoneId> = (0..document.skeleton.bones.len()).collect();
490    let source_topology = whole_document_source_topology(document);
491    let ledger = compile_scale_ledger(
492        document,
493        ScaleOperation::WholeDocumentLinearUnits { factor },
494        &affected_nodes,
495        &[],
496        &source_topology,
497    );
498    Ok(ScalePlan {
499        tolerance_policy: ScaleTolerancePolicy::APPENDIX_D_V6,
500        // Declared, not measured — see [`ScalePlan::observed_factor`].
501        observed_factor: factor,
502        affected_nodes,
503        source_topology,
504        ledger,
505        compiled: ScaleCompiledPlan::WholeDocument(WholeDocumentParams { factor }),
506    })
507}
508
509fn plan_rest_bind(
510    document: &Document,
511    source_skin_index: usize,
512    source_root_node_index: usize,
513    expected_factor: f64,
514) -> Result<ScalePlan, ScaleError> {
515    if !expected_factor.is_finite() || expected_factor <= 0.0 {
516        return Err(ScaleError::InvalidExpectedFactor {
517            factor: expected_factor,
518        });
519    }
520    // Both directions: the builder narrows `expected_factor` itself, and the
521    // proof's basis correction `C = scale(1 / s)` narrows its reciprocal.
522    check_factor_narrows(expected_factor, expected_factor)?;
523    check_factor_narrows(expected_factor, 1.0 / expected_factor)?;
524    let domain = derive_rest_bind_plan_domain(document, source_skin_index, source_root_node_index)?;
525    let by_source_index = source_node_index_map(document);
526    let bone_of_source = domain.bone_of_source();
527    let connector_sources = domain.connector_sources();
528
529    let tol = ScaleTolerancePolicy::APPENDIX_D_V6;
530    let mut world_cache = BTreeMap::new();
531    let mut node_factor: BTreeMap<BoneId, f64> = BTreeMap::new();
532    for (&source, &bone) in &bone_of_source {
533        let world = source_world_matrix(
534            source,
535            &by_source_index,
536            &connector_sources,
537            &mut world_cache,
538        )?;
539        let linear = Mat3::from_mat4(world);
540        let factor = classify_affine(linear, &tol)
541            .map_err(|reason| ScaleError::InvalidAffineDomain { node: bone, reason })?;
542        node_factor.insert(bone, factor);
543    }
544    // Read at the scaled root, which DESIGN.md Appendix D §D.6 names, rather
545    // than at whichever affected node happens to sort first. The two readings
546    // were separable before this module related its two parent chains; under
547    // the source-projection agreement [`crate::model::validate_document_shape`]
548    // establishes they are provably the same node, and the proof is written out on
549    // `observed_factor_from_source` (§ "The scaled root is the minimum
550    // BoneId in the closure"). Naming the root explicitly keeps this reading
551    // and that second witness pinned to the same definition rather than to a
552    // coincidence of ordering.
553    let observed_common = node_factor[&domain.scaled_root_bone];
554    if !tol.relative(tol.common_factor, observed_common, expected_factor) {
555        return Err(ScaleError::FactorMismatch {
556            expected: expected_factor,
557            observed: observed_common,
558        });
559    }
560    for (&bone, &factor) in &node_factor {
561        if bone == domain.scaled_root_bone {
562            continue;
563        }
564        if !tol.relative(tol.common_factor, factor, observed_common) {
565            return Err(ScaleError::MixedFactor {
566                expected: observed_common,
567                observed: factor,
568                node: bone,
569            });
570        }
571    }
572
573    let affected_nodes = domain.affected_nodes();
574    let transform_only_attachments = domain.transform_only_attachments();
575    let operation = ScaleOperation::RestBindUniformScale {
576        source_skin_index,
577        source_root_node_index,
578        expected_factor,
579    };
580    let ledger = compile_scale_ledger(
581        document,
582        operation,
583        &affected_nodes,
584        &transform_only_attachments,
585        &domain.source_rows,
586    );
587    Ok(ScalePlan {
588        tolerance_policy: tol,
589        // The measured source fact, kept alongside the declared factor the
590        // build applies rather than discarded once it has been validated
591        // against it (DESIGN.md Appendix D §D.6, "declared and observed
592        // factors").
593        observed_factor: observed_common,
594        affected_nodes,
595        source_topology: domain.source_rows,
596        ledger,
597        compiled: ScaleCompiledPlan::RestBind(RestBindParams {
598            source_skin_index,
599            source_root_node_index,
600            expected_factor,
601            transform_only_attachments,
602        }),
603    })
604}
605
606/// Re-derive the document-dependent part of `plan` against the source a
607/// builder or proof was actually handed.
608///
609/// Numerical replay remains intentional: proof may independently observe
610/// different transform values from those planning read. Structural replay is
611/// different. Every proof loop is selected by the affected domain and the
612/// evidence inventory, so accepting a source that derives a wider domain or
613/// different obligations would let a stale plan omit payload altogether.
614pub(in crate::scale) fn validate_plan_document_inventory(
615    document: &Document,
616    plan: &ScalePlan,
617) -> Result<(), ScaleError> {
618    validate_scale_input(document)?;
619    let validate = |affected_nodes: &[BoneId],
620                    source_topology: &[ScaleSourceTopologyRow],
621                    transform_only_attachments: &[BoneId],
622                    ledger: &ScaleLedger| {
623        let reason = if affected_nodes != plan.affected_nodes() {
624            Some("affected_nodes_mismatch")
625        } else if source_topology != plan.source_topology {
626            Some("affected_source_topology_mismatch")
627        } else if transform_only_attachments != plan.transform_only_attachments() {
628            Some("transform_only_attachments_mismatch")
629        } else if ledger.obligations != plan.obligations() {
630            Some("proof_obligations_mismatch")
631        } else if ledger.payload_shapes != plan.ledger.payload_shapes {
632            Some("payload_shape_inventory_mismatch")
633        } else if ledger.field_rows != plan.field_rows() {
634            Some("field_write_set_mismatch")
635        } else {
636            None
637        };
638        if let Some(reason) = reason {
639            return Err(ScaleError::PlanDocumentMismatch { reason });
640        }
641        Ok(())
642    };
643
644    match plan.operation() {
645        ScaleOperation::WholeDocumentLinearUnits { factor } => {
646            let derived = plan_whole_document(document, factor)?;
647            validate(
648                derived.affected_nodes(),
649                &derived.source_topology,
650                derived.transform_only_attachments(),
651                &derived.ledger,
652            )?;
653            Ok(())
654        }
655        ScaleOperation::RestBindUniformScale {
656            source_skin_index,
657            source_root_node_index,
658            ..
659        } => {
660            let domain =
661                derive_rest_bind_plan_domain(document, source_skin_index, source_root_node_index)?;
662            let affected_nodes = domain.affected_nodes();
663            let transform_only_attachments = domain.transform_only_attachments();
664            let ledger = compile_scale_ledger(
665                document,
666                plan.operation(),
667                &affected_nodes,
668                &transform_only_attachments,
669                &domain.source_rows,
670            );
671            validate(
672                &affected_nodes,
673                &domain.source_rows,
674                &transform_only_attachments,
675                &ledger,
676            )?;
677            Ok(())
678        }
679    }
680}
681
682pub(in crate::scale) fn classify_affine(
683    linear: Mat3,
684    tol: &ScaleTolerancePolicy,
685) -> Result<f64, AffineDomainViolation> {
686    classify_positive_uniform_affine(
687        linear,
688        PositiveUniformAffineTolerance {
689            equal_axis: tol.equal_axis,
690            relative_orthogonality: tol.relative_orthogonality,
691            singular_determinant_relative: tol.singular_determinant_relative,
692        },
693    )
694}
695
696/// Which of the clip-driven obligations `document` carries evidence for
697/// inside `affected`.
698///
699/// Each field is exactly the condition under which that obligation's residual
700/// loop reads at least one payload, so a plan that declares the obligation is
701/// declaring something [`super::prove_scale`] will actually check, and the residual
702/// it reports is a measurement rather than the zero an empty loop leaves
703/// behind. Computed the same way on both sides of the plan/proof boundary:
704/// [`plan_scale`] uses it to decide what to declare, and [`super::prove_scale`] uses
705/// it to fail closed when a declared obligation's evidence is not in the
706/// document it was handed.
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
708struct SampledEvidence {
709    /// Some clip declares a translation track on an affected bone. That
710    /// track is both what puts key times into proof's `clip_sample_times` walk and the
711    /// only payload its `check_track_value_residual` walk compares, so it is the
712    /// whole evidence base for [`ScaleProofObligation::KeyTranslations`].
713    key_translations: bool,
714    /// Some clip declares *both* an affected cubic-spline track with at least
715    /// two key times — which is what produces an interior time at all — and
716    /// an affected translation track, which is what the comparison at that
717    /// interior time reads. The two need not be the same track, but they must
718    /// be in the same clip: interior times are harvested per clip and
719    /// compared against that clip's tracks.
720    cubic_interiors: bool,
721    /// Some clip yields at least one sample time for an affected bone, of
722    /// either kind. The trajectory obligation compares composed world
723    /// matrices rather than track payloads, so any affected track's key times
724    /// are evidence for it.
725    sample_times: bool,
726}
727
728/// Measure [`SampledEvidence`] over `document`'s clips.
729fn sampled_evidence(document: &Document, affected: &BTreeSet<BoneId>) -> SampledEvidence {
730    let mut evidence = SampledEvidence::default();
731    for clip in &document.clips {
732        let mut translations = false;
733        let mut cubic_segments = false;
734        for track in &clip.tracks {
735            if !affected.contains(&track.bone) || track.times.is_empty() {
736                continue;
737            }
738            evidence.sample_times = true;
739            translations |= track.property == Property::Translation;
740            cubic_segments |=
741                track.interpolation == Interpolation::CubicSpline && track.times.len() >= 2;
742        }
743        evidence.key_translations |= translations;
744        evidence.cubic_interiors |= translations && cubic_segments;
745    }
746    evidence
747}