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