Skip to main content

animsmith_core/scale/
mod.rs

1//! Format-neutral scale plan and proof contracts (DESIGN.md Appendix D).
2//!
3//! This module owns the two distinct scale operations Appendix D defines —
4//! [`ScaleOperation::WholeDocumentLinearUnits`] and
5//! [`ScaleOperation::RestBindUniformScale`] — plus their shared pure
6//! planning, candidate construction, and proof layer. It deliberately
7//! consumes and returns only format-neutral facts: an already-loaded
8//! [`Document`] and a [`ScaleCapabilityFacts`] projection that a format
9//! frontend (for example `animsmith-gltf`'s raw capability preflight)
10//! builds from its own source-specific inventory. This module does not
11//! accept paths, glTF/ufbx types, config parsers, or publication policy,
12//! and it does not itself decide CLI selectors, evidence schemas, or
13//! artifact/evidence publication — those are producer concerns layered on
14//! top.
15//!
16//! The public vocabulary and entrypoints continue to resolve through this
17//! facade. Private implementation modules own numeric leaves, validation,
18//! planning/replay, reference construction, and proof respectively; proof's
19//! residual recorder is nested under proof so its paired maximum/count state
20//! cannot be mutated outside that implementation boundary.
21//!
22//! [`ScaleOperation::RestBindUniformScale`] selects by raw, format-neutral
23//! source identity — `source_skin_index` and `source_root_node_index` — not
24//! by normalized [`crate::model::BoneId`] or mesh-instance ordinal.
25//! Resolving those selectors, and classifying the affected domain's affine
26//! shape, walks [`crate::model::SceneAssets::source_skeleton`]: the only
27//! place a full (possibly sheared) authored local matrix survives, since
28//! [`crate::model::Bone::rest`] is a lossy TRS decomposition that can never
29//! look sheared even when the source was.
30//!
31//! [`plan_scale`] is pure and fail-closed: it never mutates its input and
32//! returns a typed [`ScaleError`] for every unsupported affine domain,
33//! incomplete closure, incomplete capability, invalid selector, invalid
34//! factor. An internal reference builder constructs analytic candidates for
35//! fixtures and calibration; production format frontends instead rewrite
36//! exact source bytes and wrap the emitted reload with
37//! [`ScaleCandidate::from_document`]. [`prove_scale`] independently re-derives
38//! the plan's claims from the source and candidate documents and reports the observed
39//! residual maxima against the fixed [`ScaleTolerancePolicy::APPENDIX_D_V6`]
40//! tolerance identity.
41//!
42//! Those residuals are the producer evidence record of §D.6, which is why
43//! two properties of this module are contracts rather than implementation
44//! details. Every typed [`ScaleProofObligation`] is declared only when
45//! the planned document carries the evidence for it. Candidate construction
46//! and proof re-derive that structural inventory and report a stale plan as
47//! [`ScaleError::PlanDocumentMismatch`]; a counterpart missing inside an
48//! inventory-matched walk is [`ScaleError::MissingProofEvidence`]. Neither
49//! case becomes a zero residual — a record asserting `0.0` for something
50//! nothing checked would be false, not merely incomplete. The two
51//! observed-factor fields §D.6 asks for are both recorded, together with
52//! [`ScaleProof::observed_factor_divergence`] between them, so the record
53//! states their relationship instead of leaving a consumer to guess which to
54//! trust. Rest/bind derives them independently from raw and normalized state;
55//! whole-document conversion records its declared factor in both because it
56//! has no source factor to measure.
57
58use crate::model::{
59    AffineDomainViolation, BoneId, Document, DocumentShapeError, Interpolation, Property,
60    SourceInverseBindAccessorStatus, SourceSkeletonCoverage,
61};
62#[cfg(test)]
63use crate::model::{
64    Clip, MeshInstanceShapeViolation, Skeleton, TrackValues, Transform, mat4_is_finite,
65};
66#[cfg(test)]
67use glam::{Mat3, Mat4, Vec4};
68#[cfg(test)]
69use std::collections::BTreeMap;
70use std::collections::BTreeSet;
71
72mod assembly_basis;
73mod numeric;
74mod planning;
75mod proof;
76mod reference;
77mod validation;
78
79pub use assembly_basis::{
80    ASSEMBLY_SCALE_BASIS_VERSION, AssemblyScaleBasis, AssemblyScaleCompatibilityError,
81    AssemblyScaleNamedNode, AssemblyScaleSourceNode, AssemblyScaleSourceRest,
82    AssemblyScaleTargetPath, assembly_scale_basis, require_assembly_scale_compatibility,
83};
84pub use planning::plan_scale;
85use planning::validate_plan_document_inventory;
86pub use proof::{ScaleProof, ScaleProofResidual, prove_scale};
87pub use reference::ScaleCandidate;
88
89#[cfg(test)]
90use proof::{
91    BoundsAccumulator, SkinSlot, accumulate_skinned_bounds, check_residual, check_sampling_budget,
92    observed_factor_from_source, per_sample_work_units, skin_influence_magnitude, world_at_time,
93};
94
95#[cfg(test)]
96use numeric::{
97    column_operand_magnitude, largest_entry, mat4_abs, product_operand_magnitude,
98    scale_translation_only, translation_composition_rounding_base,
99};
100#[cfg(test)]
101use planning::classify_affine;
102#[cfg(any(test, feature = "fixtures"))]
103pub(crate) use reference::build_scale_candidate;
104#[cfg(test)]
105use reference::{build_rest_bind, build_whole_document};
106#[cfg(test)]
107use validation::{
108    WorldBonePose, WorldPose, affected_skin_instance_indices, child_translation_rounding_magnitude,
109    instance_bind, rest_world_pose, source_node_index_map, validate_scale_input,
110};
111#[cfg(test)]
112use validation::{
113    affected_skin_classification_steps, derive_rest_bind_plan_domain,
114    reset_affected_skin_classification_steps, resolve_rest_bind_skin, rest_bind_affected_closure,
115    source_world_matrix, world_rests,
116};
117
118// --- Tolerance policy ----------------------------------------------------
119
120/// Fixed Appendix D tolerance identity and thresholds. Classification and
121/// proof share this one versioned policy and compute in `f64`, narrowing
122/// only at the writer model boundary. There is exactly one supported
123/// instance, [`ScaleTolerancePolicy::APPENDIX_D_V6`]: a policy change is a
124/// new policy identity, not a runtime knob.
125///
126/// The superseded v5 identity is deliberately not retained as an alias:
127///
128/// ```compile_fail
129/// use animsmith_core::ScaleTolerancePolicy;
130///
131/// let _ = ScaleTolerancePolicy::APPENDIX_D_V5;
132/// ```
133#[derive(Debug, Clone, Copy, PartialEq)]
134#[non_exhaustive]
135pub struct ScaleTolerancePolicy {
136    /// Stable policy identity recorded in producer evidence.
137    pub id: &'static str,
138    /// Relative orthogonality tolerance for rejecting shear.
139    pub relative_orthogonality: f64,
140    /// Relative tolerance for equal-length affine columns (uniform scale).
141    pub equal_axis: f64,
142    /// Relative tolerance for one common factor across an affected domain.
143    ///
144    /// This is the normative input band: it is what an operator's declared
145    /// factor is judged against, and
146    /// [`Self::postcondition_unit_scale_residual`] is derived from it so that
147    /// a plan this band accepts is guaranteed to produce a candidate that
148    /// satisfies the unit-scale postcondition.
149    pub common_factor: f64,
150    /// `abs(det) <= singular_determinant_relative * product(axis_lengths)`
151    /// classifies a linear part as singular.
152    pub singular_determinant_relative: f64,
153    /// Absolute term of the scalar/vector comparison tolerance.
154    pub scalar_absolute: f64,
155    /// Relative term of the scalar/vector comparison tolerance.
156    pub scalar_relative: f64,
157    /// Maximum shortest-path rotation residual, in radians.
158    pub rotation_residual_radians: f64,
159    /// Maximum postcondition unit-scale residual, measured **per axis**
160    /// (L-infinity) as `max(|scale_axis - 1|)` — not as an L2 norm over the
161    /// three axes.
162    ///
163    /// The norm is normative, and it is the same dimensionless per-axis
164    /// relative quantity [`Self::common_factor`] and [`Self::equal_axis`]
165    /// measure, so the input band and this postcondition are directly
166    /// commensurable (DESIGN.md Appendix D §D.1). This value is *derived*
167    /// from [`Self::common_factor`] rather than declared independently: see
168    /// [`Self::APPENDIX_D_V6`] for the composition argument and
169    /// [`Self::UNIT_SCALE_BANDS`] for the multiplier.
170    pub postcondition_unit_scale_residual: f64,
171    /// Maximum sampled proof work [`prove_scale`] will perform, in
172    /// per-sample-time work units.
173    ///
174    /// Total work is `sample_time_count * per_sample_work_units`, where the
175    /// per-sample cost counts every pass the sampled obligations actually
176    /// make — bones, skin slots, and skinned vertices, each once per document
177    /// side. See the private `per_sample_work_units` for the exact formula
178    /// and for why the slot term cannot be folded into either of the other
179    /// two. A
180    /// document above this budget is refused with
181    /// [`ScaleError::ProofSamplingBudgetExceeded`] *before* any sampling
182    /// runs; proof never silently samples a subset.
183    ///
184    /// This is part of the versioned policy identity, not a per-run flag —
185    /// DESIGN.md Appendix D §D.6/§D.7 forbid per-run tolerance knobs, and a
186    /// budget that changed per run would make two evidence records carrying
187    /// the same policy id describe different amounts of checking.
188    pub proof_sample_work_budget: u64,
189    /// How many binary32 ulps of *operand* magnitude an obligation that
190    /// compares `f32`-rounded arithmetic may deviate by, on top of
191    /// [`Self::scalar_absolute`] and [`Self::scalar_relative`].
192    ///
193    /// The term this multiplies is **absolute**, not relative: it is
194    /// `f32_rounding_ulps * magnitude * f32::EPSILON` where `magnitude` is
195    /// the largest quantity the compared arithmetic passed through, not the
196    /// quantity being compared. Where the compared value *is* that largest
197    /// quantity the term adds `4 * 2^-23 = 4.77e-7` of it — twenty times
198    /// less than [`Self::scalar_relative`] already allows — so it cannot
199    /// loosen the obligations it applies to in their own regime.
200    ///
201    /// It exists for the regime where the two diverge. A rotation can make
202    /// the compared quantity orders of magnitude smaller than the operands
203    /// it was computed from while it still carries those operands' absolute
204    /// rounding error: a bound component near zero on a mesh 4000 units
205    /// across, a near-identity `W * B` whose translation column cancelled two
206    /// 3190-magnitude terms, or a world translation whose parent chain
207    /// cancelled two of them one composition earlier. A purely relative band
208    /// is then derived from the small number and the error from the large
209    /// one, and [`prove_scale`] refuses a correct candidate that
210    /// [`plan_scale`] accepted. See [`Self::APPENDIX_D_V6`] for the
211    /// measurement this count comes from and DESIGN.md Appendix D §D.1 for
212    /// which magnitude each obligation takes it from.
213    ///
214    /// The count is only as meaningful as that magnitude. Two revisions of
215    /// this policy have now found the *base* wrong rather than the count too
216    /// small — first the skinned extent alone, which missed the `W * B`
217    /// composition, then `abs(W) * abs(B)` alone, which missed what `W`'s own
218    /// parent chain had already cancelled — and in both the measured excess
219    /// was hundreds of thousands of ulps, not a factor of two. A residual
220    /// above this count is evidence about the base before it is evidence
221    /// about the count.
222    pub f32_rounding_ulps: u32,
223}
224
225impl ScaleTolerancePolicy {
226    /// How many [`Self::common_factor`] bands
227    /// [`Self::postcondition_unit_scale_residual`] is derived from.
228    ///
229    /// Three of them are analytic and one is float headroom; see
230    /// [`Self::APPENDIX_D_V6`].
231    pub const UNIT_SCALE_BANDS: f64 = 4.0;
232
233    /// The only supported tolerance policy: DESIGN.md Appendix D, version 6.
234    ///
235    /// Version 6 supersedes `appendix-d-v5`, which superseded v4, v3, v2 and
236    /// v1. Each identity change is a change of *meaning*, not a retune:
237    ///
238    /// 1. [`Self::postcondition_unit_scale_residual`] is a per-axis
239    ///    (L-infinity) residual derived from [`Self::common_factor`], instead
240    ///    of v1's independently declared `1e-5` L2 norm over three axes. Under
241    ///    v1 the two were incommensurable, and a source whose observed factor
242    ///    had relative error `e` produced a postcondition residual of
243    ///    `sqrt(3) * e`, so every `e` in `(5.77e-6, 1e-5]` was accepted by
244    ///    [`plan_scale`] and then rejected by [`prove_scale`].
245    /// 2. [`Self::proof_sample_work_budget`] bounds the sampled proof work a
246    ///    document may demand.
247    /// 3. [`Self::f32_rounding_ulps`] is new in v3, and adds an absolute
248    ///    `f32`-rounding term to the five obligations that compare
249    ///    `f32`-rounded arithmetic against a base that a rotation can make
250    ///    arbitrarily smaller than the operands the arithmetic ran on —
251    ///    [`ProofResidualKind::Bounds`], [`ProofResidualKind::SkinMatrix`],
252    ///    [`ProofResidualKind::UnaffectedInverseBind`],
253    ///    [`ProofResidualKind::RestTranslation`], and
254    ///    [`ProofResidualKind::Trajectory`]. Without it [`plan_scale`] accepts
255    ///    and [`prove_scale`] refuses a correct candidate whenever
256    ///    `magnitude / component` is large.
257    /// 4. v4 widens finite non-negative weight normalization and accumulation
258    ///    to binary64, makes the Bounds magnitude a weight-proportional
259    ///    combination of each influence's transform and slot-composition
260    ///    provenance, and removes v3's blended-point L2 stage. Bounds residuals
261    ///    are per axis, and the normalized blend is already bounded by those
262    ///    weighted operands.
263    /// 5. v5 makes parent-chain translation provenance additive per composed
264    ///    link, in binary64, instead of taking a depth-independent maximum.
265    ///    Each spatial row carries the new local contribution plus a parent
266    ///    term capped by `contribution / EPSILON`. This provisions the smaller
267    ///    of one parent-scale ulp and losing the entire contribution, so zero
268    ///    and underflowed descendants cannot charge the same translated parent
269    ///    repeatedly. Only the three spatial output rows participate; the
270    ///    affine homogeneous row contributes zero under this cap. The same
271    ///    recurrence constructs rest and sampled poses, and its result reaches
272    ///    RestTranslation, Trajectory, SkinMatrix and Bounds through their
273    ///    existing consumers.
274    /// 6. v6 changes only the association of the shared affine axis-length
275    ///    mean: the three finite widened lengths are sorted ascending before
276    ///    the ordinary sum and division by three. This removes authored-column
277    ///    order from the classifier, planning, and proof witness without
278    ///    changing any numeric threshold, the v5 parent-chain provenance
279    ///    recurrence, or the evidence schema.
280    ///
281    /// `postcondition_unit_scale_residual` is
282    /// `UNIT_SCALE_BANDS * common_factor = 4e-5`, rounded up to the next
283    /// power of two, `2^-14 = 6.103515625e-5`. That value is also
284    /// `512 * 2^-23`, and so lies on the binary32 mantissa grid the
285    /// composed-scale measurement lives on. Landing on that grid is what
286    /// makes §D.1's inclusive "at most" reachable for this obligation: the
287    /// measured residual near unit magnitude is always an integer multiple of
288    /// `2^-23`, so a bound off that grid could never be met with equality and
289    /// would be an exclusive bound wearing an inclusive name.
290    ///
291    /// The four bands are:
292    ///
293    /// - one for [`ScaleError::FactorMismatch`], which binds the domain's
294    ///   observed common factor `s_0` to the caller's declared factor
295    ///   `s_declared`;
296    /// - one for [`ScaleError::MixedFactor`], which binds each affected node's
297    ///   observed factor `s_i` to `s_0`;
298    /// - one for [`AffineDomainViolation::NonUniformScale`], which binds each
299    ///   individual *axis* of node `i` to `s_i`; and
300    /// - one reserved as headroom for the `f32` world-matrix composition and
301    ///   decomposition that produces the measured composed scale.
302    ///
303    /// The first three compose, and the third is easy to miss: `s_i` is the
304    /// *average* of node `i`'s three world axis lengths (the affine
305    /// classifier returns that average), while the postcondition measures an
306    /// individual
307    /// axis, and the equal-axis check permits each axis its own further band
308    /// away from that average. The candidate's composed scale on axis `k` of
309    /// node `i` is `axis_ik / s_declared`, and each of the three bands is
310    /// stated relative to `max` of its operands, so each contributes at most
311    /// `c / (1 - c)` when re-expressed relative to the smaller one. The
312    /// analytic worst case is therefore `(1 - c)^-3 - 1 = 3.00006e-5` for
313    /// `c = 1e-5`.
314    ///
315    /// Three bands rounded up (`2^-15 = 3.0517578125e-5`) would leave that
316    /// worst case only `4` binary32 ulps of room — `2^-15 - 3.00006e-5 =
317    /// 5.17e-7 = 4.34 * 2^-23` — which is not headroom for a float
318    /// measurement, it is a rounding artefact. A fourth band makes the
319    /// reserved-headroom claim above true rather than aspirational, and it
320    /// does not blunt the obligation: every build defect this check exists to
321    /// catch — a dropped rebase, a factor applied twice, a stale no-op — is
322    /// `>= 1e-3`, so `6.1e-5` still leaves better than a `16x` detection
323    /// margin.
324    pub const APPENDIX_D_V6: Self = Self {
325        id: "appendix-d-v6",
326        relative_orthogonality: 1e-5,
327        equal_axis: 1e-5,
328        common_factor: 1e-5,
329        singular_determinant_relative: 1e-6,
330        scalar_absolute: 1e-6,
331        scalar_relative: 1e-5,
332        rotation_residual_radians: 1e-5,
333        // 2^-14, exactly: four `common_factor` bands rounded up onto the
334        // binary32 mantissa grid (`= 512 * 2^-23`).
335        postcondition_unit_scale_residual: 6.103_515_625e-5,
336        // DESIGN.md Appendix D §D.1 owns the exact charge and released
337        // resource boundary. docs/scale-calibration.md records the historical
338        // populations and machine-local timings that selected this value.
339        proof_sample_work_budget: 400_000_000,
340        // Empirically calibrated, not an analytic bound. The checked-in
341        // `calibrate_f32_rounding_ulps` sweep can be regenerated with
342        //
343        //   cargo test -p animsmith-core --release --lib \
344        //       calibrate_f32_rounding_ulps -- --ignored --nocapture
345        //
346        // docs/scale-calibration.md owns the exact populations, measured
347        // demands, historical alternatives, and cost discussion. DESIGN.md
348        // Appendix D §D.1 owns the shipped recurrence and refusal semantics.
349        f32_rounding_ulps: 4,
350    };
351
352    /// The expected ceiling on [`ScaleProof::observed_factor_divergence`]:
353    /// [`Self::common_factor`] plus
354    /// [`Self::postcondition_unit_scale_residual`], `7.103515625e-5` under
355    /// [`Self::APPENDIX_D_V6`].
356    ///
357    /// For rest/bind, [`ScalePlan::observed_factor`] and
358    /// [`ScaleProof::observed_factor`] are independent witnesses measured from
359    /// genuinely different state — the raw source projection composed through
360    /// `parent_source_node_index`, and the normalized skeleton composed
361    /// through `world_rest_matrices`. Their independence is the point. For
362    /// whole-document conversion both fields are the declared factor, so their
363    /// divergence is exactly zero. For rest/bind, the sum comes from:
364    ///
365    /// - planning binds its witness to the caller's declared factor within
366    ///   [`Self::common_factor`], or refuses with
367    ///   [`ScaleError::FactorMismatch`]; and
368    /// - for a candidate the internal reference builder produced from the
369    ///   source
370    ///   under proof, that candidate's composed root scale is the proof
371    ///   witness divided by the declared factor, so the unit-scale
372    ///   postcondition binds the proof witness to the declared factor within
373    ///   [`Self::postcondition_unit_scale_residual`].
374    ///
375    /// The two bands are not stated the same way, and the sum is a ceiling
376    /// only up to that difference. Planning's is relative to the `max` of its
377    /// two operands, exactly as this divergence is. The postcondition's is not
378    /// a relative band on the two witnesses at all: it is an absolute
379    /// L-infinity deviation from `1` on the *candidate's* composed scale, and
380    /// that candidate's scale is the proof witness rebased by the declared
381    /// factor — so what it bounds is `|proved - declared|` as a fraction of
382    /// the declared factor, not as a fraction of `max(planned, proved)`.
383    ///
384    /// **Reported, not enforced, and expected rather than proved.** Nothing
385    /// refuses a document for exceeding this. The second step above holds for
386    /// a candidate this module built from the source it is being proved
387    /// against, which [`prove_scale`] deliberately does not require, and it
388    /// costs the binary32 rounding of the rebase on the way — so the sum is
389    /// the ceiling the design guarantees, not a bound proved to the last ulp.
390    /// A divergence beyond it means the two witnesses were composed from state
391    /// that does not agree — most often differing *stored* transforms, since
392    /// [`crate::model::SourceNodeAsset::local_rest`] and
393    /// [`crate::model::Bone::rest`] are separately stored descriptions of the
394    /// same rest pose. It is not evidence of disagreeing parent chains: under
395    /// [`crate::model::SourceSkeletonCoverage::Complete`] coverage the two
396    /// chains are required to describe the same tree, and every entry point in
397    /// this module refuses a document where they do not.
398    ///
399    /// Derived from two bands this policy already declares rather than
400    /// introduced as a third, so it adds no tolerance and no policy identity —
401    /// and a consumer of the evidence record does not have to sum two
402    /// separate policy fields to know what the recorded divergence means.
403    pub fn observed_factor_divergence_ceiling(&self) -> f64 {
404        self.common_factor + self.postcondition_unit_scale_residual
405    }
406
407    /// `abs_error <= scalar_absolute + scalar_relative * max(abs(before), abs(after))`.
408    ///
409    /// Every proof call site must pass the actual before/after magnitudes of
410    /// the specific residual being checked — never a proxy such as the
411    /// plan's declared factor — so a residual near a large coordinate gets a
412    /// correspondingly looser absolute tolerance than one near a small
413    /// coordinate.
414    pub fn scalar_tolerance(&self, before: f64, after: f64) -> f64 {
415        self.scalar_absolute + self.scalar_relative * before.abs().max(after.abs())
416    }
417
418    /// [`Self::scalar_tolerance`] plus [`Self::f32_rounding_ulps`] binary32
419    /// ulps of `magnitude`.
420    ///
421    /// `magnitude` is the largest quantity the compared `f32` arithmetic
422    /// passed through — never the quantity being compared, which is what
423    /// `before`/`after` already carry. The two coincide for a comparison
424    /// whose operands are its own magnitude, and diverge without limit for
425    /// one whose result was made small by cancellation; DESIGN.md Appendix D
426    /// §D.1 names the magnitude each obligation takes.
427    ///
428    /// The added term is absolute in `magnitude` and so cannot widen a
429    /// comparison relative to its own operands: at `magnitude ==
430    /// max(before, after)` it is `f32_rounding_ulps * 2^-23 = 4.77e-7` of
431    /// them, against the `1e-5` [`Self::scalar_relative`] already allows.
432    pub fn f32_rounded_tolerance(&self, before: f64, after: f64, magnitude: f64) -> f64 {
433        self.scalar_tolerance(before, after)
434            + f64::from(self.f32_rounding_ulps) * magnitude.abs() * f64::from(f32::EPSILON)
435    }
436
437    /// `abs(a - b) <= tolerance * max(abs(a), abs(b))`.
438    ///
439    /// Genuinely relative, per DESIGN.md Appendix D §D.1: the orthogonality,
440    /// equal-axis, and common-factor tolerances are declared *relative*
441    /// `1e-5`, so the comparison base is the operands' own magnitude and
442    /// nothing else. Flooring that base at `1.0` — as an earlier revision did
443    /// — silently converts these into absolute tolerances for every operand
444    /// below unit magnitude, which is exactly the regime these operations
445    /// exist for: at a common factor of `0.01` a `1.0` floor accepts `1e-3`
446    /// relative error, `100x` the declared policy, and lets `plan_scale`
447    /// accept a plan whose candidate then fails its own unit-scale
448    /// postcondition.
449    ///
450    /// There is **no** floor on the comparison base — not `1.0`, and not
451    /// [`Self::scalar_absolute`] either. A `scalar_absolute` floor is a
452    /// smaller version of the same defect and breaks the same closure
453    /// property, just further down: below `1e-6` the band stops tracking the
454    /// operands and freezes at the constant `1e-5 * 1e-6 = 1e-11`, which is a
455    /// *relative* band of `1e-11 / abs(s)` and therefore widens without limit
456    /// as `s` shrinks. It crosses
457    /// [`Self::postcondition_unit_scale_residual`] at
458    /// `s = 1e-11 / 2^-14 = 1e-11 * 16384 = 1.6384e-7` (`3.2768e-7` against
459    /// the tighter `2^-15` bound an earlier revision declared — halving the
460    /// postcondition bound doubles the crossing point, because the crossing
461    /// point is inversely proportional to it), so every declared factor
462    /// below that had a band of accepted plans whose candidates then failed
463    /// the unit-scale postcondition — at `s = 1e-9` the band admits `1e-2`
464    /// relative error, `1000x` the declared policy.
465    ///
466    /// Nothing needs the floor for the degenerate `a == b == 0` case either:
467    /// that compares `0.0 <= 0.0`, which holds. (Both call sites have already
468    /// proved their operands strictly positive in any case — a declared
469    /// factor by `planning::plan_rest_bind`'s range check, an observed one by
470    /// [`planning::classify_affine`].)
471    fn relative(&self, tolerance: f64, a: f64, b: f64) -> bool {
472        (a - b).abs() <= tolerance * a.abs().max(b.abs())
473    }
474}
475
476// --- Capability projection ------------------------------------------------
477
478/// Whether a format-neutral capability projection covers the whole source.
479///
480/// A projection built from an incomplete or partially-inspected source must
481/// report [`ScaleCapabilityCoverage::Unavailable`]: an absent flag is not
482/// evidence the underlying domain is absent from the source.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
484pub enum ScaleCapabilityCoverage {
485    /// The projection cannot vouch for the complete source domain.
486    #[default]
487    Unavailable,
488    /// Every documented domain in the source was inspected.
489    Complete,
490}
491
492/// Format-neutral capability facts a frontend projects from its raw source
493/// inventory before any scale plan or candidate exists.
494///
495/// This is deliberately coarser than a format's own raw capability
496/// manifest (for example `animsmith_gltf::GltfCapabilityManifest`): it only
497/// carries the flags this module's planning needs to fail closed on an
498/// unsupported domain, per DESIGN.md Appendix D §D.4. A frontend projects
499/// its richer, format-specific manifest down to these flags.
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
501#[non_exhaustive]
502pub struct ScaleCapabilityFacts {
503    /// Whether the projection covers the complete source domain.
504    pub coverage: ScaleCapabilityCoverage,
505    /// A morph target is present.
506    pub morphs_present: bool,
507    /// Static or animated morph weights are present.
508    pub morph_weights_present: bool,
509    /// The format adapter owns exact preservation of every present morph
510    /// payload for whole-document conversion.
511    ///
512    /// Presence alone is never permission: this witness is set only after a
513    /// raw adapter has validated its operation-specific write set and proof.
514    pub whole_document_morphs_preservable: bool,
515    /// A camera is present.
516    pub cameras_present: bool,
517    /// A punctual light is present.
518    pub lights_present: bool,
519    /// GPU-instancing data is present.
520    pub instancing_present: bool,
521    /// An extension is not covered by a registered length-field handler.
522    pub unregistered_extensions_present: bool,
523    /// Non-null application-specific extras are present.
524    pub extras_present: bool,
525    /// A JSON/source member outside the modeled schema was ignored.
526    pub unknown_source_members_present: bool,
527    /// A non-triangle-list primitive is present.
528    pub non_triangle_primitives_present: bool,
529    /// A vertex attribute outside the normalized writer subset is present.
530    pub unsupported_vertex_attributes_present: bool,
531    /// A secondary skin-influence set is present.
532    pub secondary_skin_influences_present: bool,
533    /// An inverse-bind accessor is missing, empty, mismatched, or unreadable.
534    pub inverse_bind_issues_present: bool,
535    /// A scale-bearing source layout cannot be safely bounded or rewritten.
536    pub unsafe_accessor_layout_present: bool,
537    /// An external (non-embedded) resource is referenced.
538    pub external_resources_present: bool,
539}
540
541impl ScaleCapabilityFacts {
542    /// A capability projection declaring complete coverage and no
543    /// unsupported domain for either operation.
544    ///
545    /// This operation-agnostic query stays conservative for callers that do
546    /// not yet have an operation in hand. Planning uses
547    /// [`Self::is_supported_for`] so a format adapter that owns raw morph
548    /// preservation can admit morphs for whole-document conversion without
549    /// weakening the rest/bind boundary.
550    pub fn is_supported(&self) -> bool {
551        self.common_domains_supported() && !self.morphs_present && !self.morph_weights_present
552    }
553
554    /// Whether this complete projection is supported for `operation`.
555    ///
556    /// Morph targets are deliberately operation-specific. A
557    /// whole-document format adapter may scale raw `POSITION` deltas and
558    /// preserve dimensionless weights outside [`crate::Document`]; rest/bind
559    /// still refuses every morph because its raw preservation proof has not
560    /// been defined. All other capability domains retain the same refusal for
561    /// both operations.
562    pub fn is_supported_for(&self, operation: ScaleOperation) -> bool {
563        self.common_domains_supported()
564            && match operation {
565                ScaleOperation::WholeDocumentLinearUnits { .. } => {
566                    (!self.morphs_present && !self.morph_weights_present)
567                        || self.whole_document_morphs_preservable
568                }
569                ScaleOperation::RestBindUniformScale { .. } => {
570                    !self.morphs_present && !self.morph_weights_present
571                }
572            }
573    }
574
575    fn common_domains_supported(&self) -> bool {
576        self.coverage == ScaleCapabilityCoverage::Complete
577            && !self.cameras_present
578            && !self.lights_present
579            && !self.instancing_present
580            && !self.unregistered_extensions_present
581            && !self.extras_present
582            && !self.unknown_source_members_present
583            && !self.non_triangle_primitives_present
584            && !self.unsupported_vertex_attributes_present
585            && !self.secondary_skin_influences_present
586            && !self.inverse_bind_issues_present
587            && !self.unsafe_accessor_layout_present
588            && !self.external_resources_present
589    }
590}
591
592// --- Operation and request -------------------------------------------------
593
594/// The two distinct scale operations DESIGN.md Appendix D §D.1 defines.
595///
596/// Neither variant infers its factor or applicability from mesh bounds,
597/// character height, joint lengths, inverse-bind magnitude, filename, or an
598/// asset category. The caller names the operation and declares or accepts
599/// the exact factor [`plan_scale`] validates.
600#[derive(Debug, Clone, Copy, PartialEq)]
601#[non_exhaustive]
602pub enum ScaleOperation {
603    /// Whole-document linear-unit conversion: every represented length is
604    /// converted by the declared finite positive `factor`.
605    WholeDocumentLinearUnits {
606        /// Declared finite positive conversion factor `q`.
607        factor: f64,
608    },
609    /// Rest/bind hierarchy reparameterization: removes one compensating
610    /// inherited scale from a restricted skinned hierarchy.
611    ///
612    /// Both selectors are raw, format-neutral source identity — a source
613    /// node/skin array index, per DESIGN.md Appendix D §D.7 — not a
614    /// normalized [`BoneId`] or mesh-instance ordinal. [`plan_scale`]
615    /// resolves them through [`crate::model::SceneAssets::source_skeleton`].
616    RestBindUniformScale {
617        /// Stable source-skin-array index selecting the skin whose joints
618        /// anchor the affected domain.
619        source_skin_index: usize,
620        /// Stable source-node-array index of the scaled ancestor root.
621        source_root_node_index: usize,
622        /// Caller-declared expected common factor `s`. Planning measures
623        /// the source's observed rest-world factor and rejects a mismatch
624        /// rather than inferring `s` from geometry.
625        expected_factor: f64,
626    },
627}
628
629/// Pure planning input: the operation, the document to plan against, and a
630/// format-neutral capability projection of the raw source.
631#[derive(Debug, Clone, Copy)]
632pub struct ScaleRequest<'a> {
633    /// Selected operation and its declared parameters.
634    pub operation: ScaleOperation,
635    /// Document to plan against.
636    pub document: &'a Document,
637    /// Format-neutral capability projection of the raw source.
638    pub capability: &'a ScaleCapabilityFacts,
639}
640
641// --- Errors ----------------------------------------------------------------
642
643/// Typed, fail-closed rejection from [`plan_scale`], reference candidate
644/// construction, or [`prove_scale`].
645#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
646#[non_exhaustive]
647pub enum ScaleError {
648    /// The whole-document conversion factor is not finite and positive.
649    #[error("scale factor must be finite and positive, got {factor}")]
650    InvalidFactor {
651        /// The rejected factor.
652        factor: f64,
653    },
654    /// The declared rest/bind expected factor is not finite and positive.
655    #[error("rest/bind expected factor must be finite and positive, got {factor}")]
656    InvalidExpectedFactor {
657        /// The rejected factor.
658        factor: f64,
659    },
660    /// A declared factor (or a factor derived from it, such as the rest/bind
661    /// reciprocal `1 / expected_factor`) is finite and positive in `f64` but
662    /// has no usable `f32` image at the writer model boundary: it either
663    /// overflows to infinity or flushes a nonzero factor to zero.
664    ///
665    /// This is deliberately a distinct variant from
666    /// [`ScaleError::InvalidFactor`] / [`ScaleError::InvalidExpectedFactor`],
667    /// whose message would be an outright lie here — `1e-50` *is* finite and
668    /// positive; what it is not is representable once the model narrows to
669    /// `f32`. Rejecting it at plan time is what stops a build from silently
670    /// multiplying every translation, mesh `POSITION`, and inverse-bind
671    /// translation by `0.0f32` and handing the annihilated document to a
672    /// proof that then signs off on it, because `0 == 0 * 0` within any
673    /// tolerance.
674    #[error(
675        "factor {factor} (derived from declared factor {declared}) is not representable at the f32 writer model boundary: it narrows to {narrowed}"
676    )]
677    FactorNotRepresentable {
678        /// The declared factor the caller supplied.
679        declared: f64,
680        /// The declared factor, or the reciprocal derived from it, that
681        /// failed to narrow. Equal to `declared` when the declared factor
682        /// itself failed.
683        factor: f64,
684        /// The unusable `f32` image of `factor`.
685        narrowed: f32,
686    },
687    /// `source_root_node_index` is not a source node in the document's
688    /// source skeleton.
689    #[error(
690        "source root node index {source_root_node_index} is not a source node in the document's source skeleton"
691    )]
692    InvalidRootSelector {
693        /// The rejected source-node index.
694        source_root_node_index: usize,
695    },
696    /// `source_skin_index` is not a skin in the document's source skeleton,
697    /// or the skin declares no joints.
698    #[error(
699        "source skin index {source_skin_index} is not a skin in the document's source skeleton, or has no joints"
700    )]
701    InvalidSkinSelector {
702        /// The rejected source-skin index.
703        source_skin_index: usize,
704    },
705    /// The capability projection is unavailable or declares an unsupported
706    /// domain.
707    #[error("capability projection is incomplete or declares unsupported domain(s)")]
708    IncompleteCapability,
709    /// `document.assets.source_skeleton` does not declare complete coverage.
710    #[error(
711        "document.assets.source_skeleton coverage is not complete: rest/bind planning requires a format-neutral source-node/source-skin projection"
712    )]
713    IncompleteSourceSkeleton,
714    /// A selected root, selected skin joint, or terminal affected source row
715    /// did not normalize to a document skeleton bone. Unprojected rows are
716    /// otherwise accepted only when they are strict connectors between
717    /// projected rows.
718    #[error("source node {source_node_index} did not normalize to a document skeleton bone")]
719    SourceNodeNotNormalized {
720        /// The unnormalized source-node index.
721        source_node_index: usize,
722    },
723    /// A raw source-node rest transform is non-finite.
724    #[error("source node {source_node_index} has a non-finite raw rest transform")]
725    NonFiniteSourceTransform {
726        /// The source node with the non-finite transform.
727        source_node_index: usize,
728    },
729    /// A transform composed during scale planning, candidate construction,
730    /// or proof is non-finite. Entry-time skeleton rest failures are reported
731    /// through [`ScaleError::InvalidDocumentShape`].
732    #[error("node {node} has a non-finite rest transform")]
733    NonFiniteTransform {
734        /// The node with the non-finite transform.
735        node: BoneId,
736    },
737    /// A runtime scale walk encountered a parent that cannot be resolved.
738    /// Entry-time skeleton topology failures are reported through
739    /// [`ScaleError::InvalidDocumentShape`].
740    #[error("node {node} has invalid parent {parent}")]
741    InvalidParent {
742        /// The node with an invalid parent.
743        node: BoneId,
744        /// The invalid parent index.
745        parent: BoneId,
746    },
747    /// A plan or document reference a bone index outside
748    /// `document.skeleton.bones` for the document actually supplied.
749    ///
750    /// This guards every boundary where a [`ScalePlan`] built from one
751    /// document could be replayed against a different one: [`ScalePlan`]
752    /// has no public constructor other than [`plan_scale`], but
753    /// reference candidate construction and [`prove_scale`] each take the
754    /// document to operate on as a separate argument and must not trust that
755    /// it still matches the plan's shape.
756    #[error("bone index {index} is out of range for this document")]
757    BoneIndexOutOfRange {
758        /// The out-of-range index.
759        index: usize,
760    },
761    /// A plan replayed against a document derives a different write or proof
762    /// inventory than it did when planned.
763    ///
764    /// Plans may be reused across numerically different documents, but only
765    /// while re-deriving the supplied source's structural planning inventory
766    /// selects the same complete domain. Otherwise a stale affected-node list
767    /// or evidence flag could leave newly introduced payload outside every
768    /// proof walk.
769    #[error("plan does not describe the supplied document: {reason}")]
770    PlanDocumentMismatch {
771        /// Stable machine-readable mismatch kind.
772        reason: &'static str,
773    },
774    /// The affected closure could not be completed.
775    #[error("affected domain closure is not complete: {reason}")]
776    IncompleteClosure {
777        /// Stable machine-readable reason.
778        reason: &'static str,
779    },
780    /// Unskinned geometry is attached inside the affected closure.
781    #[error("node {node} carries unskinned geometry inside the affected closure")]
782    UnsupportedUnskinnedGeometry {
783        /// The node carrying unskinned geometry.
784        node: BoneId,
785    },
786    /// A node's rest-world linear part is outside the supported affine class.
787    #[error(
788        "node {node} rest-world linear part is not orientation-preserving positive uniform scale ({reason:?})"
789    )]
790    InvalidAffineDomain {
791        /// The rejected node.
792        node: BoneId,
793        /// Stable machine-readable violation kind.
794        reason: AffineDomainViolation,
795    },
796    /// The declared `expected_factor` does not match the source's observed
797    /// common factor.
798    #[error("declared expected factor {expected} does not match observed source factor {observed}")]
799    FactorMismatch {
800        /// Declared expected factor.
801        expected: f64,
802        /// Observed source factor.
803        observed: f64,
804    },
805    /// One node's effective factor differs from the domain's common factor.
806    #[error("node {node} effective factor {observed} differs from common factor {expected}")]
807    MixedFactor {
808        /// The domain's common factor.
809        expected: f64,
810        /// The node's observed factor.
811        observed: f64,
812        /// The node with the mismatched factor.
813        node: BoneId,
814    },
815    /// A proof residual exceeded the fixed tolerance policy.
816    #[error("proof residual {observed} for {kind:?} exceeds tolerance {tolerance}")]
817    ProofResidualExceeded {
818        /// Which proof obligation failed.
819        kind: ProofResidualKind,
820        /// Observed residual.
821        observed: f64,
822        /// Tolerance the residual exceeded.
823        tolerance: f64,
824    },
825    /// The document's sampled proof work exceeds
826    /// [`ScaleTolerancePolicy::proof_sample_work_budget`].
827    ///
828    /// Raised by [`prove_scale`] *before* any sample time is evaluated, so a
829    /// document whose key count and vertex count multiply out beyond the
830    /// versioned policy's budget is refused outright rather than proved
831    /// against a silently truncated subset of its sample times. The budget is
832    /// a property of the policy identity recorded in evidence, not a per-run
833    /// flag.
834    #[error(
835        "proof sampling work {work} ({sample_times} sample times x {per_sample_cost} work units) exceeds the {policy_id} budget {budget}"
836    )]
837    ProofSamplingBudgetExceeded {
838        /// The tolerance-policy identity whose budget was exceeded.
839        policy_id: &'static str,
840        /// Distinct sample times the plan's obligations would evaluate,
841        /// summed over every clip.
842        sample_times: u64,
843        /// Work units one sample time costs: `bone_count` plus the vertex
844        /// count of every skinned instance inside the affected closure.
845        per_sample_cost: u64,
846        /// `sample_times * per_sample_cost`, saturating.
847        work: u64,
848        /// The policy's [`ScaleTolerancePolicy::proof_sample_work_budget`].
849        budget: u64,
850    },
851    /// The plan's typed obligation ledger declared a claim provable, but
852    /// [`prove_scale`] could not find the evidence to check it (for example
853    /// a clip or track present in `source` with no counterpart in
854    /// `candidate`). This is a distinct failure from
855    /// [`ScaleError::ProofResidualExceeded`]: the claim was never checked at
856    /// all, so proof must fail rather than silently report a zero residual.
857    #[error("proof obligation {kind:?} could not find expected evidence ({detail})")]
858    MissingProofEvidence {
859        /// Which proof obligation was left unchecked.
860        kind: ProofResidualKind,
861        /// Stable machine-readable reason.
862        detail: &'static str,
863    },
864    /// The shared model shape required by strict mutating operations is
865    /// malformed. [`Document`] is publicly mutable, so planning, building,
866    /// and proof validate each supplied snapshot independently.
867    #[error(transparent)]
868    InvalidDocumentShape(#[from] DocumentShapeError),
869    /// No inverse-bind evidence exists for a skin joint: the owning mesh
870    /// instance declares an empty `skin_ibms` (falling back to the bone's
871    /// own [`crate::model::Bone::inverse_bind`]) and that bone also has no
872    /// inverse-bind matrix. Identity is never substituted for genuinely
873    /// missing evidence — only for a source skin whose complete-coverage
874    /// [`crate::model::SourceSkinAsset::inverse_bind_accessor`] proves the
875    /// format-defined identity default with
876    /// [`crate::model::SourceInverseBindAccessorStatus::Absent`] (checked
877    /// internally by this module's private inverse-bind resolution).
878    #[error("no inverse-bind evidence for skin joint {node}")]
879    MissingInverseBind {
880        /// The joint with no inverse-bind evidence.
881        node: BoneId,
882    },
883    /// A mesh primitive is malformed independently of any skin: a non-finite
884    /// base `POSITION`.
885    ///
886    /// Checked at every public entry point, on the candidate as well as the
887    /// input, because base `POSITION` is a rewritten domain: without it a
888    /// whole-document build with an overflowing factor returns a document
889    /// full of non-finite vertices as `Ok`.
890    #[error("mesh {mesh_index} primitive {primitive_index} is invalid ({reason})")]
891    InvalidMeshPrimitive {
892        /// Index into `document.assets.meshes` of the offending mesh.
893        mesh_index: usize,
894        /// Index into that mesh's `primitives` of the offending primitive.
895        primitive_index: usize,
896        /// Stable machine-readable reason.
897        reason: &'static str,
898    },
899    /// A primary skin-weight attribute contains a finite negative value.
900    ///
901    /// Skin weights are coefficients of a convex blend, never signed affine
902    /// coefficients. Refusing this at the shared scale-input boundary keeps
903    /// planning, candidate construction, and proof on that one semantic
904    /// domain and gives evidence consumers a stable kind without requiring
905    /// them to parse a [`ScaleError::InvalidMeshPrimitive`] reason string.
906    #[error(
907        "mesh {mesh_index} primitive {primitive_index} vertex {vertex_index} primary skin influence {influence_index} has a negative weight"
908    )]
909    NegativeSkinWeight {
910        /// Index into `document.assets.meshes`.
911        mesh_index: usize,
912        /// Index into that mesh's `primitives`.
913        primitive_index: usize,
914        /// Vertex carrying the rejected weight tuple.
915        vertex_index: usize,
916        /// Component within the primary four-influence tuple.
917        influence_index: usize,
918    },
919    /// A skinned primitive is malformed: `joints`/`weights` shorter than
920    /// `positions`, a non-finite position or weight, a joint-influence slot
921    /// outside the owning instance's `skin_joints`, or a skinned result that
922    /// is not finite.
923    ///
924    /// The last case reports two distinct `reason`s. A skinned position that
925    /// left the `f32` range is `"skinned_magnitude_overflow"`: the document's
926    /// geometry does not fit the arithmetic this proof runs in. A `NaN` is
927    /// `"non_finite_result"`: an input that survived every finiteness check
928    /// above is degenerate in some other way. Both fail closed; neither is
929    /// bounded by a magnitude domain, because skinning accumulates a dot
930    /// product per axis and where that overflows depends on the rotation
931    /// rather than on the magnitude of the result.
932    #[error("instance {instance_index} primitive {primitive_index} is invalid ({reason})")]
933    InvalidSkinnedPrimitive {
934        /// Index into `document.assets.instances` of the owning instance.
935        instance_index: usize,
936        /// Index into the owning mesh's `primitives` of the offending
937        /// primitive.
938        primitive_index: usize,
939        /// Stable machine-readable reason.
940        reason: &'static str,
941    },
942    /// `candidate`'s skeleton/source-projection, clip/track/instance/mesh/
943    /// primitive structure does not match `source`'s, or an exact unchanged
944    /// semantic value differs. This includes a changed parent or source-node
945    /// projection, a changed world-rest affine outside a rest/bind closure, a
946    /// missing or extra clip, track, instance, mesh, or primitive, a track
947    /// whose identity, interpolation, times, or value shape disagrees with
948    /// its source counterpart, or a mesh instance whose identity — the node
949    /// it hangs off, the source node it came from, the mesh it draws, or the
950    /// joints it binds — disagrees with its source counterpart. Proof pairs
951    /// source and candidate structure by identity or index, which requires
952    /// this parity to hold. For rest/bind this also covers an admitted static
953    /// connector local that changed bits or a projected successor whose raw
954    /// local is not the independently derived bridged rebase. An extra,
955    /// missing, re-parented, relocated, or otherwise rewritten unchanged
956    /// value is never silently ignored.
957    #[error("candidate document structure does not match source ({reason})")]
958    CandidateStructureMismatch {
959        /// Stable machine-readable reason.
960        reason: &'static str,
961    },
962}
963
964/// Which proof obligation produced a [`ScaleError::ProofResidualExceeded`]
965/// or [`ScaleError::MissingProofEvidence`].
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
967#[non_exhaustive]
968pub enum ProofResidualKind {
969    /// Rest-world translation residual.
970    RestTranslation,
971    /// Rest-world rotation residual.
972    RestRotation,
973    /// Postcondition unit-scale residual.
974    UnitScale,
975    /// Transform-only attachment full-affine residual (an off-origin point
976    /// transformed through the expected and actual world matrix), per
977    /// DESIGN.md Appendix D §D.2/§D.6.
978    TransformOnlyAffine,
979    /// Per-element animation-track value residual, checked directly against
980    /// each domain's analytic expectation: a rewritten translation element
981    /// (value *or* cubic tangent) against `before * multiplier`, and every
982    /// retained rotation/scale element against `before` itself.
983    ///
984    /// Distinct from [`Self::KeyTranslation`], which samples the *composed*
985    /// track at key times: sampling proves what an evaluator would read, but
986    /// only a direct element comparison proves both that rewritten domains
987    /// received their declared multiplier and that domains this plan declares
988    /// untouched really are untouched.
989    TrackValue,
990    /// Base mesh `POSITION` residual, per vertex, against this operation's
991    /// analytic expectation (`before * q` for whole-document conversion,
992    /// `before` for rest/bind reparameterization).
993    MeshPosition,
994    /// Keyframe-time translation residual.
995    KeyTranslation,
996    /// Cubic-segment interior-time translation residual.
997    CubicInterior,
998    /// Sampled world-space trajectory residual.
999    Trajectory,
1000    /// Skin-matrix (`W * B`) residual.
1001    SkinMatrix,
1002    /// Skinned mesh bounds residual.
1003    Bounds,
1004    /// Effective inverse-bind residual for a skin slot *outside* the affected
1005    /// closure — a skin neither operation touches, whose binds must therefore
1006    /// come through unchanged.
1007    ///
1008    /// Slots inside the closure are covered, more strongly, by
1009    /// [`Self::SkinMatrix`]: that obligation compares the composed `W * B`,
1010    /// which is what actually deforms a vertex. Outside the closure there is
1011    /// no rebase to compose against, so each slot is compared through the
1012    /// model's inverse-bind fallback chain.
1013    UnaffectedInverseBind,
1014    /// The factor [`prove_scale`] re-derived from the documents it was given.
1015    /// Only ever reported as [`ScaleError::MissingProofEvidence`]: it names a
1016    /// source whose scaled root the proof could not resolve, never a residual.
1017    ObservedFactor,
1018}
1019
1020// --- Plan --------------------------------------------------------------
1021
1022/// The structural semantic operation a rewritten field receives.
1023///
1024/// No variant stores a resolved factor or expected value. Candidate
1025/// construction and proof independently resolve the selected operation's
1026/// numeric arithmetic from the field identity and topology.
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028#[non_exhaustive]
1029pub enum ScaleRewriteRule {
1030    /// A whole-document linear-unit length field.
1031    WholeDocumentLength,
1032    /// A rest/bind field governed by the target node's parent-basis factor.
1033    RestBindParentBasis,
1034    /// A rest/bind field governed by the local `s_parent / s_node` rebase.
1035    RestBindLocalScale,
1036    /// A rest/bind inverse bind governed by its joint's node-basis factor.
1037    RestBindNodeBasis,
1038    /// A projected source-local rest, optionally bridged through connectors.
1039    RestBindSourceLocal {
1040        /// The immediate connector tail below the projected parent, if any.
1041        connector_tail: Option<usize>,
1042    },
1043}
1044
1045/// Whether a modeled field is preserved exactly or analytically rewritten.
1046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1047#[non_exhaustive]
1048pub enum ScaleFieldDisposition {
1049    /// The field is outside the core builder's write set.
1050    ///
1051    /// This is ownership, not a universal normalized-artifact equality
1052    /// promise: format frontends may independently re-derive normalized
1053    /// bones, binds, tracks, or meshes within the established residual
1054    /// policy. Authored raw source-local fields copied by the core builder are
1055    /// additionally checked bit-exact by [`prove_scale`].
1056    PreserveExact,
1057    /// The field is in the write set and receives the stated semantic rule.
1058    Rewrite(ScaleRewriteRule),
1059}
1060
1061/// One normalized bone-rest field.
1062#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1063#[non_exhaustive]
1064pub enum ScaleBoneRestField {
1065    /// Local translation.
1066    Translation,
1067    /// Local rotation.
1068    Rotation,
1069    /// Local scale.
1070    Scale,
1071}
1072
1073/// One authored source-node rest field or component group.
1074#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1075#[non_exhaustive]
1076pub enum ScaleSourceRestField {
1077    /// TRS translation.
1078    Translation,
1079    /// TRS rotation.
1080    Rotation,
1081    /// TRS scale.
1082    Scale,
1083    /// Matrix linear columns.
1084    MatrixLinear,
1085    /// Matrix translation column.
1086    MatrixTranslation,
1087    /// Matrix homogeneous row.
1088    MatrixHomogeneous,
1089}
1090
1091/// The exact container-level target of one field disposition.
1092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1093#[non_exhaustive]
1094pub enum ScaleFieldTarget {
1095    /// One normalized bone-rest field.
1096    BoneRest {
1097        /// Normalized bone identity.
1098        bone: BoneId,
1099        /// Rest field.
1100        field: ScaleBoneRestField,
1101    },
1102    /// One authored source-node rest field.
1103    SourceNodeRest {
1104        /// Raw source-node identity.
1105        source_node_index: usize,
1106        /// Rest field or component group.
1107        field: ScaleSourceRestField,
1108    },
1109    /// One animation track's stored values.
1110    AnimationValues {
1111        /// Clip position in the normalized document.
1112        clip_index: usize,
1113        /// Track position inside the clip.
1114        track_index: usize,
1115        /// Target normalized bone.
1116        bone: BoneId,
1117        /// Animated property.
1118        property: Property,
1119    },
1120    /// One bone convenience inverse bind.
1121    BoneInverseBind {
1122        /// Normalized bone identity.
1123        bone: BoneId,
1124    },
1125    /// One logical instance inverse-bind slot.
1126    InstanceInverseBind {
1127        /// Instance position in the normalized document.
1128        instance_index: usize,
1129        /// Slot position inside the instance skin.
1130        slot: usize,
1131        /// Joint named by the slot.
1132        joint: BoneId,
1133    },
1134    /// One primitive's complete base-position array.
1135    MeshPositions {
1136        /// Mesh position in the normalized document.
1137        mesh_index: usize,
1138        /// Primitive position inside the mesh.
1139        primitive_index: usize,
1140    },
1141    /// One primitive's preserved normal array.
1142    MeshNormals {
1143        /// Mesh position in the normalized document.
1144        mesh_index: usize,
1145        /// Primitive position inside the mesh.
1146        primitive_index: usize,
1147    },
1148}
1149
1150/// One exact semantic field row in a compiled scale plan.
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1152#[non_exhaustive]
1153pub struct ScaleFieldPlan {
1154    target: ScaleFieldTarget,
1155    disposition: ScaleFieldDisposition,
1156    element_count: usize,
1157}
1158
1159impl ScaleFieldPlan {
1160    /// The exact container-level field target.
1161    pub fn target(&self) -> ScaleFieldTarget {
1162        self.target
1163    }
1164
1165    /// Whether and how the target is rewritten.
1166    pub fn disposition(&self) -> ScaleFieldDisposition {
1167        self.disposition
1168    }
1169
1170    /// Number of stored elements covered by the container row.
1171    pub fn element_count(&self) -> usize {
1172        self.element_count
1173    }
1174}
1175
1176/// One numeric-value-free payload-shape row used by stale-plan replay.
1177///
1178/// Rows include empty containers and structural identities/counts, but never
1179/// key times or stored floating-point values, so intentional numeric replay
1180/// against an identically shaped document remains supported.
1181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1182#[non_exhaustive]
1183pub enum ScalePayloadShapeRow {
1184    /// Top-level normalized collection counts.
1185    Document {
1186        /// Skeleton bone count.
1187        bone_count: usize,
1188        /// Authoritative source-skeleton node count; zero under unavailable
1189        /// coverage.
1190        source_node_count: usize,
1191        /// Whether the source projection claims complete coverage.
1192        source_coverage: SourceSkeletonCoverage,
1193        /// Clip count.
1194        clip_count: usize,
1195        /// Mesh-instance count.
1196        instance_count: usize,
1197        /// Mesh count.
1198        mesh_count: usize,
1199    },
1200    /// One normalized topology row.
1201    Bone {
1202        /// Normalized bone identity.
1203        bone: BoneId,
1204        /// Normalized parent identity.
1205        parent: Option<BoneId>,
1206    },
1207    /// One source skin's complete structural inventory.
1208    SourceSkin {
1209        /// Raw source-skin identity.
1210        source_skin_index: usize,
1211        /// Explicit source skeleton root.
1212        skeleton_root_source_node_index: Option<usize>,
1213        /// Declared joint count.
1214        joint_count: usize,
1215        /// Attachment count.
1216        attachment_count: usize,
1217        /// Inverse-bind accessor status.
1218        inverse_bind_status: SourceInverseBindAccessorStatus,
1219        /// Declared inverse-bind accessor count.
1220        inverse_bind_declared_count: Option<usize>,
1221        /// Number of readable matrices retained.
1222        inverse_bind_matrix_count: usize,
1223    },
1224    /// One ordered source-skin joint identity.
1225    SourceSkinJoint {
1226        /// Raw source-skin identity.
1227        source_skin_index: usize,
1228        /// Joint slot.
1229        slot: usize,
1230        /// Raw source-node identity.
1231        source_node_index: usize,
1232    },
1233    /// One ordered source-skin attachment identity.
1234    SourceSkinAttachment {
1235        /// Raw source-skin identity.
1236        source_skin_index: usize,
1237        /// Attachment position.
1238        attachment_index: usize,
1239        /// Raw attachment node identity.
1240        source_node_index: usize,
1241        /// Raw mesh identity, when declared.
1242        source_mesh_index: Option<usize>,
1243    },
1244    /// One clip, including an empty clip.
1245    Clip {
1246        /// Clip position.
1247        clip_index: usize,
1248        /// Track count.
1249        track_count: usize,
1250    },
1251    /// One track's structural identity and arities.
1252    Track {
1253        /// Clip position.
1254        clip_index: usize,
1255        /// Track position.
1256        track_index: usize,
1257        /// Target bone.
1258        bone: BoneId,
1259        /// Animated property.
1260        property: Property,
1261        /// Interpolation mode.
1262        interpolation: Interpolation,
1263        /// Number of key times, without storing their numeric values.
1264        key_count: usize,
1265        /// Number of stored value elements.
1266        value_count: usize,
1267    },
1268    /// One mesh instance, including an unskinned instance.
1269    Instance {
1270        /// Instance position.
1271        instance_index: usize,
1272        /// Normalized attachment node.
1273        node: BoneId,
1274        /// Raw source-node attachment identity.
1275        source_node_index: usize,
1276        /// Mesh identity.
1277        mesh: usize,
1278        /// Logical joint-slot count.
1279        joint_count: usize,
1280        /// Stored instance inverse-bind count.
1281        inverse_bind_count: usize,
1282    },
1283    /// One logical joint slot, preserving slot order and identity.
1284    InstanceJoint {
1285        /// Instance position.
1286        instance_index: usize,
1287        /// Slot position.
1288        slot: usize,
1289        /// Joint identity.
1290        joint: BoneId,
1291    },
1292    /// One mesh, including an empty mesh.
1293    Mesh {
1294        /// Mesh position.
1295        mesh_index: usize,
1296        /// Stable source mesh identity.
1297        source_mesh_index: usize,
1298        /// Primitive count.
1299        primitive_count: usize,
1300    },
1301    /// One primitive's modeled shape.
1302    Primitive {
1303        /// Mesh position.
1304        mesh_index: usize,
1305        /// Primitive position.
1306        primitive_index: usize,
1307        /// Base-position count.
1308        position_count: usize,
1309        /// Preserved normal count.
1310        normal_count: usize,
1311        /// Primary joint-tuple count read by skin/bounds proof.
1312        joint_count: usize,
1313        /// Primary weight-tuple count read by skin/bounds proof.
1314        weight_count: usize,
1315    },
1316}
1317
1318/// One typed proof claim kind derived from the plan's validated inventory.
1319///
1320/// Exact members are not duplicated here: inspect [`ScalePlan::affected_nodes`],
1321/// [`ScalePlan::transform_only_attachments`], and the field, payload, and
1322/// topology rows exposed by [`ScalePlan::ledger`].
1323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1324#[non_exhaustive]
1325pub enum ScaleProofObligation {
1326    /// Preserve normalized parents and complete source projection topology.
1327    ExactTopology,
1328    /// Preserve clip, track, instance, skin, mesh, and primitive identities.
1329    ExactPayloadIdentity,
1330    /// Preserve exact world rest for the nodes outside a rest/bind closure.
1331    ExactUnchangedWorldRest,
1332    /// Prove affected rest-world translation and orientation.
1333    RestWorld,
1334    /// Prove affected rest-world facts and the nested unit-scale postcondition.
1335    RestWorldAndUnitScale,
1336    /// Probe complete expected affines of transform-only attachments.
1337    TransformOnlyAffine,
1338    /// Compare all rewritten and preserved animation values.
1339    TrackValues,
1340    /// Compare all rewritten and preserved base positions.
1341    MeshPositions,
1342    /// Compare affected translation tracks at their key times.
1343    KeyTranslations,
1344    /// Compare affected translation tracks at bounded cubic interior times.
1345    CubicInteriors,
1346    /// Compare sampled world-space trajectories.
1347    Trajectories,
1348    /// Run the one shared affected-skin walk producing skin and bounds results.
1349    SkinAndBounds,
1350    /// Check rewritten inverse-bind slots.
1351    AffectedInverseBinds,
1352    /// Check preserved inverse-bind slots outside the closure.
1353    UnaffectedInverseBinds,
1354    /// Preserve connector locals and check bridged projected successors.
1355    ExactConnectorProjection,
1356}
1357
1358/// A projected source row's role in a rest/bind topology.
1359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1360#[non_exhaustive]
1361pub enum ScaleProjectedRole {
1362    /// Selected scaled root.
1363    Root,
1364    /// Selected skin joint other than the root.
1365    Joint,
1366    /// Affected non-joint attachment or path node.
1367    TransformOnly,
1368}
1369
1370/// The typed kind of one canonical rest/bind source-topology row.
1371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1372#[non_exhaustive]
1373pub enum ScaleSourceNodeKind {
1374    /// A source node projected into the normalized skeleton.
1375    Projected {
1376        /// Normalized bone identity.
1377        bone: BoneId,
1378        /// Root, joint, or transform-only role.
1379        role: ScaleProjectedRole,
1380        /// Nearest projected parent in source identity space.
1381        projected_parent: Option<usize>,
1382        /// Immediate connector tail below that projected parent, if any.
1383        incoming_connector_tail: Option<usize>,
1384    },
1385    /// A static unprojected connector preserved exactly.
1386    Connector,
1387    /// A source row outside a rest/bind domain, or any row in a
1388    /// whole-document plan where connector roles are not applicable.
1389    OutsideDomain {
1390        /// Normalized projection identity, if one exists.
1391        bone: Option<BoneId>,
1392    },
1393}
1394
1395/// One row in the canonical source-keyed rest/bind topology.
1396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1397#[non_exhaustive]
1398pub struct ScaleSourceTopologyRow {
1399    source_node_index: usize,
1400    parent_source_node_index: Option<usize>,
1401    kind: ScaleSourceNodeKind,
1402}
1403
1404#[derive(Debug, Clone, PartialEq, Eq)]
1405struct ScaleLedger {
1406    field_rows: Vec<ScaleFieldPlan>,
1407    payload_shapes: Vec<ScalePayloadShapeRow>,
1408    obligations: Vec<ScaleProofObligation>,
1409}
1410
1411#[derive(Debug, Clone, PartialEq)]
1412struct WholeDocumentParams {
1413    factor: f64,
1414}
1415
1416#[derive(Debug, Clone, PartialEq)]
1417struct RestBindParams {
1418    source_skin_index: usize,
1419    source_root_node_index: usize,
1420    expected_factor: f64,
1421    transform_only_attachments: Vec<BoneId>,
1422}
1423
1424#[derive(Debug, Clone, PartialEq)]
1425enum ScaleCompiledPlan {
1426    WholeDocument(WholeDocumentParams),
1427    RestBind(RestBindParams),
1428}
1429
1430/// Read-only view of one compiled plan's exact domain, field, topology, and
1431/// proof-obligation ledger.
1432///
1433/// The view has no constructor and cannot be converted back into a
1434/// [`ScalePlan`]; only [`plan_scale`] can compile an authoritative ledger.
1435#[derive(Debug, Clone, Copy)]
1436pub struct ScalePlanLedger<'a> {
1437    plan: &'a ScalePlan,
1438}
1439
1440impl<'a> ScalePlanLedger<'a> {
1441    fn ledger(self) -> &'a ScaleLedger {
1442        &self.plan.ledger
1443    }
1444
1445    /// Exact container-level modeled field rows in deterministic source order.
1446    pub fn field_rows(self) -> std::slice::Iter<'a, ScaleFieldPlan> {
1447        self.ledger().field_rows.iter()
1448    }
1449
1450    /// Numeric-value-free payload-shape rows, including empty containers.
1451    pub fn payload_shapes(self) -> std::slice::Iter<'a, ScalePayloadShapeRow> {
1452        self.ledger().payload_shapes.iter()
1453    }
1454
1455    /// Typed proof obligations derived from the same field and payload inventory.
1456    pub fn obligations(self) -> std::slice::Iter<'a, ScaleProofObligation> {
1457        self.ledger().obligations.iter()
1458    }
1459
1460    /// Canonical source-keyed topology for the complete modeled projection.
1461    pub fn source_topology(self) -> std::slice::Iter<'a, ScaleSourceTopologyRow> {
1462        self.plan.source_topology.iter()
1463    }
1464}
1465
1466impl ScaleSourceTopologyRow {
1467    /// Raw source-node identity.
1468    pub fn source_node_index(&self) -> usize {
1469        self.source_node_index
1470    }
1471
1472    /// Authoritative raw parent identity.
1473    pub fn parent_source_node_index(&self) -> Option<usize> {
1474        self.parent_source_node_index
1475    }
1476
1477    /// Whether this row is projected or is a preserved connector.
1478    pub fn kind(&self) -> ScaleSourceNodeKind {
1479        self.kind
1480    }
1481}
1482
1483/// Pure, typed plan returned by [`plan_scale`].
1484///
1485/// Planning never mutates its input document; it only inspects it. Reference
1486/// candidate construction from an accepted plan is a distinct, separately
1487/// fallible fixture step.
1488///
1489/// Every field is private: a [`ScalePlan`] can only be produced by
1490/// [`plan_scale`], so an external caller cannot hand-construct or mutate one
1491/// into a state whose `affected_nodes` disagree with `operation`'s
1492/// selectors. Read plan contents through the accessor methods.
1493#[derive(Debug, Clone, PartialEq)]
1494#[non_exhaustive]
1495pub struct ScalePlan {
1496    tolerance_policy: ScaleTolerancePolicy,
1497    observed_factor: f64,
1498    affected_nodes: Vec<BoneId>,
1499    source_topology: Vec<ScaleSourceTopologyRow>,
1500    ledger: ScaleLedger,
1501    compiled: ScaleCompiledPlan,
1502}
1503
1504impl ScalePlan {
1505    /// Echoed operation and its declared parameters.
1506    pub fn operation(&self) -> ScaleOperation {
1507        match &self.compiled {
1508            ScaleCompiledPlan::WholeDocument(plan) => ScaleOperation::WholeDocumentLinearUnits {
1509                factor: plan.factor,
1510            },
1511            ScaleCompiledPlan::RestBind(plan) => ScaleOperation::RestBindUniformScale {
1512                source_skin_index: plan.source_skin_index,
1513                source_root_node_index: plan.source_root_node_index,
1514                expected_factor: plan.expected_factor,
1515            },
1516        }
1517    }
1518
1519    /// The fixed tolerance policy this plan and its proof share.
1520    pub fn tolerance_policy(&self) -> ScaleTolerancePolicy {
1521        self.tolerance_policy
1522    }
1523
1524    /// Affected normalized-node closure, in ascending bone-id order.
1525    ///
1526    /// For [`ScaleOperation::WholeDocumentLinearUnits`] this is every node
1527    /// in the document. For [`ScaleOperation::RestBindUniformScale`] this is
1528    /// the closed connected hierarchy of DESIGN.md Appendix D §D.2: the
1529    /// scaled ancestor, every selected skin joint and the normalized paths
1530    /// between them, and every descendant transform-only attachment. Raw
1531    /// source-only connector rows on those paths are not normalized nodes
1532    /// and therefore do not appear in this list.
1533    pub fn affected_nodes(&self) -> &[BoneId] {
1534        &self.affected_nodes
1535    }
1536
1537    /// Descendant nodes in [`Self::affected_nodes`] that carry no skin —
1538    /// the "transform-only child" case of DESIGN.md Appendix D §D.2/§D.3.
1539    /// Always empty for [`ScaleOperation::WholeDocumentLinearUnits`].
1540    pub fn transform_only_attachments(&self) -> &[BoneId] {
1541        match &self.compiled {
1542            ScaleCompiledPlan::WholeDocument(_) => &[],
1543            ScaleCompiledPlan::RestBind(plan) => &plan.transform_only_attachments,
1544        }
1545    }
1546
1547    /// The one common factor `s` (or `q` for whole-document conversion)
1548    /// applied across [`Self::affected_nodes`].
1549    ///
1550    /// This is always the factor the *caller declared*, never the one
1551    /// measured from the source: reference construction applies exactly this
1552    /// value, and [`prove_scale`] states every analytic expectation in terms
1553    /// of it. [`Self::observed_factor`] reports the measured
1554    /// counterpart, and the two are separate numbers on purpose — DESIGN.md
1555    /// Appendix D §D.6 requires producer evidence to record both.
1556    pub fn common_factor(&self) -> f64 {
1557        match &self.compiled {
1558            ScaleCompiledPlan::WholeDocument(plan) => plan.factor,
1559            ScaleCompiledPlan::RestBind(plan) => plan.expected_factor,
1560        }
1561    }
1562
1563    /// The factor this plan *observed* in the source, as distinct from the
1564    /// caller-declared [`Self::common_factor`] the build applies.
1565    ///
1566    /// For [`ScaleOperation::RestBindUniformScale`] this is the rest-world
1567    /// uniform factor measured at the scaled root of DESIGN.md Appendix D
1568    /// §D.2 — the average of its rest-world linear part's three column
1569    /// lengths, the same quantity the domain classification returns. It is
1570    /// within [`ScaleTolerancePolicy::common_factor`] of
1571    /// [`Self::common_factor`] (planning rejects it otherwise with
1572    /// [`ScaleError::FactorMismatch`]) but is generally not equal to it: a
1573    /// source authored at `0.010_000_02` is accepted against a declared
1574    /// `0.01`, and both numbers belong in evidence.
1575    ///
1576    /// For [`ScaleOperation::WholeDocumentLinearUnits`] this equals
1577    /// [`Self::common_factor`] exactly, because there is nothing to measure.
1578    /// That operation's factor is *declared*, not observed: §D.1 states that
1579    /// a whole-document conversion "changes physical size", is "appropriate
1580    /// only when the source was authored in a different linear unit", and
1581    /// that neither operation "may infer its factor or applicability from
1582    /// mesh bounds, character height, joint lengths, inverse-bind magnitude,
1583    /// filename, or an asset category". A source authored in centimetres and
1584    /// one authored in metres are numerically identical documents, so no
1585    /// measurement of either could distinguish them; the declared factor is
1586    /// the only fact there is, and reporting it here keeps the evidence
1587    /// contract uniform across the two operations rather than leaving a hole
1588    /// a consumer would have to special-case.
1589    pub fn observed_factor(&self) -> f64 {
1590        self.observed_factor
1591    }
1592
1593    /// Validate this plan's complete structural inventory against `document`.
1594    ///
1595    /// This re-derives and exactly compares the affected domain, canonical
1596    /// source topology, transform-only attachments, payload shapes, field
1597    /// dispositions, and proof obligations. Numeric source values are not
1598    /// compared, so a document with the same structural ledger remains a
1599    /// valid replay source, but the replay document must still satisfy the
1600    /// finite-value and nonnegative-weight scale-input requirements.
1601    ///
1602    /// # Errors
1603    ///
1604    /// Returns [`ScaleError::PlanDocumentMismatch`] when the re-derived
1605    /// inventory differs, or the corresponding planning/input error when
1606    /// `document` cannot produce a valid inventory for this operation.
1607    pub fn validate_document_inventory(&self, document: &Document) -> Result<(), ScaleError> {
1608        validate_plan_document_inventory(document, self)
1609    }
1610
1611    /// Inspect the exact read-only topology, field, and obligation ledger.
1612    pub fn ledger(&self) -> ScalePlanLedger<'_> {
1613        ScalePlanLedger { plan: self }
1614    }
1615
1616    /// Resolve the effective multiplier for one animation track's stored
1617    /// values from this compiled plan.
1618    ///
1619    /// This is the assembly compatibility boundary from Appendix D §D.5: a
1620    /// producer can fingerprint the exact target-basis factor without
1621    /// reproducing rest/bind arithmetic outside the shared plan. Values and
1622    /// CUBICSPLINE tangents use the same multiplier because both occupy the
1623    /// track's one typed field row.
1624    ///
1625    /// # Errors
1626    ///
1627    /// Returns [`ScaleError::PlanDocumentMismatch`] when the document or
1628    /// requested track no longer matches the compiled ledger.
1629    pub fn animation_value_factor(
1630        &self,
1631        document: &Document,
1632        clip_index: usize,
1633        track_index: usize,
1634    ) -> Result<f64, ScaleError> {
1635        validate_plan_document_inventory(document, self)?;
1636        let row = self
1637            .field_rows()
1638            .iter()
1639            .find(|row| {
1640                matches!(
1641                    row.target(),
1642                    ScaleFieldTarget::AnimationValues {
1643                        clip_index: candidate_clip,
1644                        track_index: candidate_track,
1645                        ..
1646                    } if candidate_clip == clip_index && candidate_track == track_index
1647                )
1648            })
1649            .ok_or(ScaleError::PlanDocumentMismatch {
1650                reason: "compiled_animation_row_missing",
1651            })?;
1652        let ScaleFieldTarget::AnimationValues { bone, property, .. } = row.target() else {
1653            unreachable!("the selected row is an animation row")
1654        };
1655        self.animation_target_factor_unchecked(document, bone, property)
1656    }
1657
1658    /// Resolve the effective multiplier for an animation target basis.
1659    ///
1660    /// Unlike [`Self::animation_value_factor`], this accepts a semantic target
1661    /// rather than an existing track row. Character assembly uses it to
1662    /// compare a base skeleton with independently supplied clip files before
1663    /// any channel is copied or remapped. All factor arithmetic remains owned
1664    /// by the compiled plan.
1665    ///
1666    /// # Errors
1667    ///
1668    /// Returns [`ScaleError::PlanDocumentMismatch`] when `document` no longer
1669    /// matches the plan or the target is outside its skeleton.
1670    pub fn animation_target_factor(
1671        &self,
1672        document: &Document,
1673        bone: BoneId,
1674        property: Property,
1675    ) -> Result<f64, ScaleError> {
1676        validate_plan_document_inventory(document, self)?;
1677        self.animation_target_factor_unchecked(document, bone, property)
1678    }
1679
1680    pub(in crate::scale) fn animation_target_factor_unchecked(
1681        &self,
1682        document: &Document,
1683        bone: BoneId,
1684        property: Property,
1685    ) -> Result<f64, ScaleError> {
1686        let affected = self.affected_set();
1687        let node_factor = if affected.contains(&bone) {
1688            self.common_factor()
1689        } else {
1690            1.0
1691        };
1692        let parent_factor = document
1693            .skeleton
1694            .bones
1695            .get(bone)
1696            .ok_or(ScaleError::BoneIndexOutOfRange { index: bone })?
1697            .parent
1698            .filter(|parent| affected.contains(parent))
1699            .map_or(1.0, |_| self.common_factor());
1700        Ok(match (self.operation(), property) {
1701            (ScaleOperation::WholeDocumentLinearUnits { .. }, Property::Translation) => {
1702                self.common_factor()
1703            }
1704            (ScaleOperation::WholeDocumentLinearUnits { .. }, _) => 1.0,
1705            (ScaleOperation::RestBindUniformScale { .. }, Property::Translation) => parent_factor,
1706            (ScaleOperation::RestBindUniformScale { .. }, Property::Scale) => {
1707                parent_factor / node_factor
1708            }
1709            (ScaleOperation::RestBindUniformScale { .. }, Property::Rotation) => 1.0,
1710        })
1711    }
1712
1713    fn affected_set(&self) -> BTreeSet<BoneId> {
1714        self.affected_nodes().iter().copied().collect()
1715    }
1716
1717    fn field_rows(&self) -> &[ScaleFieldPlan] {
1718        &self.ledger.field_rows
1719    }
1720
1721    fn obligations(&self) -> &[ScaleProofObligation] {
1722        &self.ledger.obligations
1723    }
1724}
1725
1726#[cfg(test)]
1727mod tests;