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