Skip to main content

animsmith_gltf/scale/
proof.rs

1//! Artifact-level proof: what the in-memory candidate proof cannot see.
2//!
3//! [`animsmith_core::scale::prove_scale`] runs on a normalized
4//! [`animsmith_core::Document`], so it can only prove claims about domains
5//! that model represents. This layer proves the rest, directly against the
6//! emitted container bytes:
7//!
8//! 1. every raw source payload the normalized document does not model
9//!    (materials, images, textures, samplers, `TANGENT`/`COLOR_n`, secondary
10//!    influences, extension payloads, names, `asset`);
11//! 2. byte preservation of every buffer byte outside the converted accessor
12//!    ranges;
13//! 3. array identities — every array length and every index-valued field,
14//!    plus honest reporting: the accessor indices and JSON pointers the
15//!    artifact says it rewrote are exactly the ones the validated binding
16//!    inventory and proof-owned disposition checks derive;
17//! 4. determinism — rewriting the same source twice yields identical bytes;
18//! 5. container integrity — GLB header and chunk framing, 4-byte padding, and
19//!    declared buffer lengths;
20//! 6. `min`/`max` consistency — the rewritten bounds still bound the
21//!    rewritten data;
22//! 7. single-narrowing agreement — every converted `f32` is bit-identical to
23//!    the one-step narrowing of `before * q`.
24//!
25//! The in-memory layer is kept, not replaced: `SkinMatrix` (`W * B`),
26//! `Trajectory`, `CubicInterior` and `Bounds` residuals live there, and
27//! re-deriving them from raw bytes would be duplicate math with a second
28//! chance to be wrong.
29//!
30//! Expected locations come from [`super::plan::GltfScalePlan`]'s validated,
31//! numeric-free raw binding inventory. This module independently interprets
32//! the compiled dispositions and derives all expected numeric values; it
33//! never asks the writer for a rule or multiplier.
34
35use super::bytes::{self, AccessorSpan};
36use super::plan::{GltfScalePlan, RawAccessorTarget, plan_mismatch};
37use super::{
38    GltfRawJsonDifference, GltfRawJsonDifferenceKind, GltfRawJsonDifferenceSummary,
39    GltfScaleArtifact, GltfScaleRewriteError,
40};
41use crate::capability::{GltfContainerKind, GltfScaleSource, raw_json_bytes};
42use crate::{LoadError, load_bytes, resolve_buffers};
43use animsmith_core::Property;
44use animsmith_core::scale::{
45    ScaleCandidate, ScaleFieldDisposition, ScaleOperation, ScalePlan, ScaleProof, ScaleRewriteRule,
46    ScaleSourceRestField, ScaleTolerancePolicy, prove_scale,
47};
48use serde_json::{Map, Value};
49use std::collections::{BTreeMap, BTreeSet};
50use std::path::Path;
51
52const GLB_JSON_CHUNK: u32 = 0x4e4f_534a;
53const GLB_BIN_CHUNK: u32 = 0x004e_4942;
54const MAX_RAW_JSON_DIFFERENCES: usize = 16;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum ProofAccessorRule {
58    AllComponents,
59    Mat4TranslationColumn,
60}
61
62impl ProofAccessorRule {
63    fn required_accessor_type(self) -> Option<&'static str> {
64        match self {
65            Self::AllComponents => None,
66            Self::Mat4TranslationColumn => Some("MAT4"),
67        }
68    }
69}
70
71fn proof_scales_component(rule: ProofAccessorRule, component: usize) -> bool {
72    match rule {
73        ProofAccessorRule::AllComponents => true,
74        ProofAccessorRule::Mat4TranslationColumn => matches!(component, 12..=14),
75    }
76}
77
78/// Observed artifact-level evidence from [`prove_rewritten_artifact`] or
79/// [`super::prove_rewritten_rest_bind`].
80#[derive(Debug, Clone, PartialEq)]
81#[non_exhaustive]
82pub struct GltfScaleArtifactProof {
83    /// The in-memory candidate proof, run on the reloaded artifact.
84    pub core: ScaleProof,
85    /// Maximum `abs(after - before * m)` across every rewritten raw element,
86    /// in JSON and in buffer payloads, where `m` is the multiplier that
87    /// element's domain analytically requires: the declared `q` for a
88    /// whole-document conversion, and `s_parent`, `s_parent / s_i` or `s_i`
89    /// per DESIGN.md Appendix D §D.2 for a rest/bind reparameterization.
90    pub length_factor_residual: f64,
91    /// Maximum `abs(after - before)` across every element that lives *inside*
92    /// a rewritten range and must nevertheless come through unchanged — a
93    /// whole-document `MAT4` linear part, a `matrix` node's homogeneous row,
94    /// an unaffected joint's inverse-bind slot, and the untouched entries of
95    /// a rewritten accessor's `min`/`max`. Everything outside a rewritten
96    /// range is proved byte-identical instead.
97    pub dimensionless_residual: f64,
98    /// Number of maximal buffer byte ranges outside the rewritten accessor
99    /// ranges that were verified byte-identical to the source.
100    pub preserved_byte_ranges: usize,
101    /// Number of unique accessors rewritten.
102    pub rewritten_accessor_count: usize,
103}
104
105/// Independently re-derive and check every artifact-level claim.
106///
107/// # Errors
108///
109/// Returns [`GltfScaleRewriteError::ArtifactProofFailed`] for the first
110/// failed claim, [`GltfScaleRewriteError::Plan`] when the reloaded candidate
111/// fails the shared core proof, and [`GltfScaleRewriteError::Load`] when the
112/// artifact cannot be re-read.
113pub fn prove_rewritten_artifact(
114    source: &GltfScaleSource,
115    artifact: &GltfScaleArtifact,
116    plan: &ScalePlan,
117) -> Result<GltfScaleArtifactProof, GltfScaleRewriteError> {
118    let tolerance = plan.tolerance_policy();
119    let ScaleOperation::WholeDocumentLinearUnits { factor } = plan.operation() else {
120        return Err(failed(
121            "plan declares a whole-document unit conversion",
122            1.0,
123            0.0,
124        ));
125    };
126    if factor != artifact.declared_factor() {
127        return Err(failed(
128            "plan factor equals the artifact's declared factor",
129            (factor - artifact.declared_factor()).abs(),
130            0.0,
131        ));
132    }
133
134    let reloaded = load_bytes(Path::new("scale-artifact"), artifact.bytes())?;
135    let core = prove_scale(
136        source.document(),
137        &ScaleCandidate::from_document(reloaded),
138        plan,
139    )
140    .map_err(GltfScaleRewriteError::Plan)?;
141
142    let (container, json_bytes) = raw_json_bytes(artifact.bytes())?;
143    if container != artifact.container() {
144        return Err(failed("artifact container kind is unchanged", 1.0, 0.0));
145    }
146    let artifact_json: Value = serde_json::from_slice(json_bytes)
147        .map_err(|error| LoadError::Malformed(format!("artifact JSON is invalid: {error}")))?;
148    let source_root = object(source.raw_json())?;
149    let artifact_root = object(&artifact_json)?;
150    let artifact_gltf = gltf::Gltf::from_slice(artifact.bytes()).map_err(LoadError::Gltf)?;
151    let artifact_buffers = resolve_buffers(&artifact_gltf, None)?;
152
153    check_container_integrity(artifact, artifact_root, &artifact_buffers)?;
154    check_array_identities(source_root, artifact_root)?;
155    let gltf_plan = GltfScalePlan::new(source, plan)?;
156
157    let mut proof = GltfScaleArtifactProof {
158        core,
159        length_factor_residual: 0.0,
160        dimensionless_residual: 0.0,
161        preserved_byte_ranges: 0,
162        rewritten_accessor_count: 0,
163    };
164
165    let mut converted_pointers = BTreeSet::new();
166    check_node_transforms(
167        source_root,
168        artifact_root,
169        &gltf_plan,
170        factor,
171        &tolerance,
172        &mut converted_pointers,
173        &mut proof,
174    )?;
175
176    let expected = scale_bearing_accessors(&gltf_plan, factor != 1.0)?;
177    if artifact.rewritten_accessors() != expected.keys().copied().collect::<Vec<_>>() {
178        return Err(failed(
179            "artifact reports exactly the accessors this proof independently derives",
180            artifact.rewritten_accessors().len() as f64,
181            expected.len() as f64,
182        ));
183    }
184    proof.rewritten_accessor_count = expected.len();
185
186    let mut spans = Vec::with_capacity(expected.len());
187    for (&accessor_index, &rule) in &expected {
188        let span = bytes::accessor_span_typed(
189            source_root,
190            source.resolved_buffers(),
191            accessor_index,
192            rule.required_accessor_type(),
193        )?;
194        let artifact_span = bytes::accessor_span_typed(
195            artifact_root,
196            &artifact_buffers,
197            accessor_index,
198            rule.required_accessor_type(),
199        )?;
200        if span != artifact_span {
201            return Err(failed(
202                "converted accessors keep their source byte layout",
203                artifact_span.start as f64,
204                span.start as f64,
205            ));
206        }
207        spans.push((span, rule));
208    }
209
210    check_converted_payloads(
211        source.resolved_buffers(),
212        &artifact_buffers,
213        &spans,
214        factor,
215        &tolerance,
216        &mut proof,
217    )?;
218    check_accessor_bounds(
219        source_root,
220        artifact_root,
221        &artifact_buffers,
222        &spans,
223        factor,
224        &tolerance,
225        &mut converted_pointers,
226        &mut proof,
227    )?;
228
229    // The artifact's own report of what it changed is evidence, so it is
230    // checked rather than trusted: `converted_pointers` was accumulated by
231    // this module's independent scan, and it is exactly the set the rewriter
232    // is allowed to have touched.
233    if artifact.rewritten_json_pointers() != converted_pointers.iter().cloned().collect::<Vec<_>>()
234    {
235        return Err(failed(
236            "artifact reports exactly the JSON pointers this proof independently derives",
237            artifact.rewritten_json_pointers().len() as f64,
238            converted_pointers.len() as f64,
239        ));
240    }
241
242    let mut allowed = converted_pointers;
243    for &buffer_index in artifact.reencoded_buffers() {
244        allowed.insert(format!("/buffers/{buffer_index}/uri"));
245    }
246    check_preserved_json(
247        source.raw_json(),
248        &artifact_json,
249        &allowed,
250        "every raw JSON location outside the converted set is preserved exactly",
251    )?;
252
253    let converted_spans: Vec<AccessorSpan> = spans.iter().map(|(span, _)| *span).collect();
254    proof.preserved_byte_ranges = check_preserved_bytes(
255        source.resolved_buffers(),
256        &artifact_buffers,
257        &converted_spans,
258    )?;
259
260    let repeat = super::rewrite_scale_plan(source, plan)?;
261    if repeat.bytes() != artifact.bytes() {
262        return Err(failed(
263            "rewriting the same source twice yields identical bytes",
264            repeat.bytes().len() as f64,
265            artifact.bytes().len() as f64,
266        ));
267    }
268    Ok(proof)
269}
270
271// --- Claims ---------------------------------------------------------------
272
273/// GLB header/chunk framing and declared buffer lengths.
274pub(super) fn check_container_integrity(
275    artifact: &GltfScaleArtifact,
276    artifact_root: &Map<String, Value>,
277    artifact_buffers: &[Vec<u8>],
278) -> Result<(), GltfScaleRewriteError> {
279    if artifact.container() == GltfContainerKind::Glb {
280        let bytes = artifact.bytes();
281        let word = |offset: usize| -> Result<u32, GltfScaleRewriteError> {
282            bytes
283                .get(offset..offset + 4)
284                .and_then(|slice| slice.try_into().ok())
285                .map(u32::from_le_bytes)
286                .ok_or_else(|| failed("GLB container is long enough for its own framing", 0.0, 1.0))
287        };
288        if &bytes[0..4.min(bytes.len())] != b"glTF" || word(4)? != 2 {
289            return Err(failed("GLB declares magic 'glTF' and version 2", 0.0, 1.0));
290        }
291        if word(8)? as usize != bytes.len() {
292            return Err(failed(
293                "GLB total length equals the emitted byte count",
294                f64::from(word(8)?),
295                bytes.len() as f64,
296            ));
297        }
298        let json_len = word(12)? as usize;
299        if word(16)? != GLB_JSON_CHUNK || !json_len.is_multiple_of(4) {
300            return Err(failed(
301                "GLB JSON chunk is typed and 4-byte padded",
302                0.0,
303                1.0,
304            ));
305        }
306        let mut offset = 20 + json_len;
307        let mut framed = offset;
308        if offset < bytes.len() {
309            let bin_len = word(offset)? as usize;
310            if word(offset + 4)? != GLB_BIN_CHUNK || !bin_len.is_multiple_of(4) {
311                return Err(failed("GLB BIN chunk is typed and 4-byte padded", 0.0, 1.0));
312            }
313            offset += 8;
314            framed = offset + bin_len;
315        }
316        if framed != bytes.len() {
317            return Err(failed(
318                "GLB chunk lengths account for every emitted byte",
319                framed as f64,
320                bytes.len() as f64,
321            ));
322        }
323    }
324    let declared = artifact_root
325        .get("buffers")
326        .and_then(Value::as_array)
327        .map(Vec::as_slice)
328        .unwrap_or_default();
329    for (buffer_index, buffer) in declared.iter().enumerate() {
330        let byte_length = buffer
331            .get("byteLength")
332            .and_then(Value::as_u64)
333            .unwrap_or_default() as usize;
334        let resolved = artifact_buffers.get(buffer_index).map_or(0, Vec::len);
335        if resolved < byte_length {
336            return Err(failed(
337                "every declared buffer byteLength is backed by resolved bytes",
338                resolved as f64,
339                byte_length as f64,
340            ));
341        }
342    }
343    Ok(())
344}
345
346/// Every top-level array keeps its length, so every index-valued field in the
347/// document still names the same element.
348pub(super) fn check_array_identities(
349    source_root: &Map<String, Value>,
350    artifact_root: &Map<String, Value>,
351) -> Result<(), GltfScaleRewriteError> {
352    const ARRAYS: &[&str] = &[
353        "accessors",
354        "animations",
355        "bufferViews",
356        "buffers",
357        "cameras",
358        "images",
359        "materials",
360        "meshes",
361        "nodes",
362        "samplers",
363        "scenes",
364        "skins",
365        "textures",
366    ];
367    for key in ARRAYS {
368        let length =
369            |root: &Map<String, Value>| root.get(*key).and_then(Value::as_array).map(Vec::len);
370        if length(source_root) != length(artifact_root) {
371            return Err(failed(
372                "every top-level array keeps its source length",
373                length(artifact_root).unwrap_or_default() as f64,
374                length(source_root).unwrap_or_default() as f64,
375            ));
376        }
377    }
378    Ok(())
379}
380
381/// Node `translation` scales; a node `matrix` scales exactly its translation
382/// column and preserves its 3x3 and homogeneous component.
383fn check_node_transforms(
384    source_root: &Map<String, Value>,
385    artifact_root: &Map<String, Value>,
386    plan: &GltfScalePlan,
387    factor: f64,
388    tolerance: &ScaleTolerancePolicy,
389    converted_pointers: &mut BTreeSet<String>,
390    proof: &mut GltfScaleArtifactProof,
391) -> Result<(), GltfScaleRewriteError> {
392    let source_nodes = source_root
393        .get("nodes")
394        .and_then(Value::as_array)
395        .map(Vec::as_slice)
396        .unwrap_or_default();
397    let artifact_nodes = artifact_root
398        .get("nodes")
399        .and_then(Value::as_array)
400        .map(Vec::as_slice)
401        .unwrap_or_default();
402    for binding in plan.node_bindings() {
403        let node_index = binding.source_node_index;
404        let before = source_nodes
405            .get(node_index)
406            .ok_or_else(|| plan_mismatch("source_node_payload_missing"))?;
407        let after = artifact_nodes
408            .get(node_index)
409            .ok_or_else(|| plan_mismatch("artifact_node_payload_missing"))?;
410        for (member, field, length, scales) in [
411            (
412                "translation",
413                ScaleSourceRestField::Translation,
414                3usize,
415                &[0usize, 1, 2] as &[usize],
416            ),
417            (
418                "matrix",
419                ScaleSourceRestField::MatrixTranslation,
420                16,
421                &[12, 13, 14],
422            ),
423        ] {
424            if (member == "translation" && !binding.translation_declared)
425                || (member == "matrix" && !binding.matrix_declared)
426            {
427                continue;
428            }
429            let Some(source_values) = before.get(member).and_then(Value::as_array) else {
430                continue;
431            };
432            let rewrites = validate_proof_whole_document_disposition(
433                plan.source_rest(node_index, field)?,
434                factor != 1.0,
435            )?;
436            if !rewrites {
437                continue;
438            }
439            let pointer = format!("/nodes/{node_index}/{member}");
440            let artifact_values = after
441                .get(member)
442                .and_then(Value::as_array)
443                .filter(|values| values.len() == length && source_values.len() == length)
444                .ok_or_else(|| {
445                    failed(
446                        "a converted node transform keeps its authored arity",
447                        0.0,
448                        length as f64,
449                    )
450                })?;
451            for component in 0..length {
452                let before = numeric(&source_values[component], &pointer)?;
453                let after = numeric(&artifact_values[component], &pointer)?;
454                if scales.contains(&component) {
455                    track_length(before, after, factor, tolerance, proof)?;
456                } else {
457                    track_dimensionless(before, after, proof)?;
458                }
459            }
460            converted_pointers.insert(pointer);
461        }
462    }
463    Ok(())
464}
465
466/// Every converted `f32` in a buffer payload is the single-step narrowing of
467/// `before * q`, and every component the rule leaves alone is bit-identical.
468fn check_converted_payloads(
469    source_buffers: &[Vec<u8>],
470    artifact_buffers: &[Vec<u8>],
471    spans: &[(AccessorSpan, ProofAccessorRule)],
472    factor: f64,
473    tolerance: &ScaleTolerancePolicy,
474    proof: &mut GltfScaleArtifactProof,
475) -> Result<(), GltfScaleRewriteError> {
476    for &(span, rule) in spans {
477        let before = bytes::read_span(source_buffers, span);
478        let after = bytes::read_span(artifact_buffers, span);
479        if before.len() != after.len() {
480            return Err(failed(
481                "a converted accessor keeps its element count",
482                after.len() as f64,
483                before.len() as f64,
484            ));
485        }
486        for (index, (&before, &after)) in before.iter().zip(&after).enumerate() {
487            if proof_scales_component(rule, index % span.components) {
488                let expected = f64::from(before) * factor;
489                if after.to_bits() != (expected as f32).to_bits() {
490                    return Err(failed(
491                        "every converted element is the single narrowing of before * q",
492                        f64::from(after),
493                        expected,
494                    ));
495                }
496                track_length(
497                    f64::from(before),
498                    f64::from(after),
499                    factor,
500                    tolerance,
501                    proof,
502                )?;
503            } else if after.to_bits() != before.to_bits() {
504                return Err(failed(
505                    "a converted accessor's dimensionless components are bit-identical",
506                    f64::from(after),
507                    f64::from(before),
508                ));
509            }
510        }
511    }
512    Ok(())
513}
514
515/// A converted accessor's `min`/`max` scale by `q` and still bound the
516/// rewritten data.
517#[allow(clippy::too_many_arguments)]
518fn check_accessor_bounds(
519    source_root: &Map<String, Value>,
520    artifact_root: &Map<String, Value>,
521    artifact_buffers: &[Vec<u8>],
522    spans: &[(AccessorSpan, ProofAccessorRule)],
523    factor: f64,
524    tolerance: &ScaleTolerancePolicy,
525    converted_pointers: &mut BTreeSet<String>,
526    proof: &mut GltfScaleArtifactProof,
527) -> Result<(), GltfScaleRewriteError> {
528    for &(span, rule) in spans {
529        let payload = bytes::read_span(artifact_buffers, span);
530        for (member, is_min) in [("min", true), ("max", false)] {
531            let pointer = format!("/accessors/{}/{member}", span.accessor_index);
532            let Some(source_bounds) = source_root
533                .get("accessors")
534                .and_then(Value::as_array)
535                .and_then(|accessors| accessors.get(span.accessor_index))
536                .and_then(|accessor| accessor.get(member))
537                .and_then(Value::as_array)
538            else {
539                continue;
540            };
541            let artifact_bounds = artifact_root
542                .get("accessors")
543                .and_then(Value::as_array)
544                .and_then(|accessors| accessors.get(span.accessor_index))
545                .and_then(|accessor| accessor.get(member))
546                .and_then(Value::as_array)
547                .filter(|bounds| bounds.len() == source_bounds.len())
548                .ok_or_else(|| {
549                    failed(
550                        "a converted accessor keeps its authored bound arity",
551                        0.0,
552                        source_bounds.len() as f64,
553                    )
554                })?;
555            for component in 0..source_bounds.len() {
556                let before = numeric(&source_bounds[component], &pointer)?;
557                let after = numeric(&artifact_bounds[component], &pointer)?;
558                if !proof_scales_component(rule, component) {
559                    track_dimensionless(before, after, proof)?;
560                    continue;
561                }
562                // A converted bound tracks `before * q` within the shared
563                // tolerance. It is deliberately not required to be exactly
564                // `before * q` or wider: narrowing `before * q` to `f32`
565                // rounds in either direction, and the binding obligation is
566                // the next one — that the emitted bound bounds the emitted
567                // bytes.
568                track_length(before, after, factor, tolerance, proof)?;
569                let observed = payload
570                    .iter()
571                    .skip(component)
572                    .step_by(span.components)
573                    .copied()
574                    .fold(
575                        if is_min {
576                            f32::INFINITY
577                        } else {
578                            f32::NEG_INFINITY
579                        },
580                        |accumulator, value| {
581                            if is_min {
582                                accumulator.min(value)
583                            } else {
584                                accumulator.max(value)
585                            }
586                        },
587                    );
588                // Compared in `f32`, the model glTF actually declares for
589                // `min`/`max`: a JSON number is `f64` in transit, but its
590                // shortest decimal spelling need not equal the full `f64`
591                // widening of the represented `f32`. Comparing in `f64`
592                // would therefore test lexical transit precision rather than
593                // the model value that matters.
594                let declared = after as f32;
595                let bounds_data = if is_min {
596                    declared <= observed
597                } else {
598                    declared >= observed
599                };
600                if !bounds_data {
601                    return Err(failed(
602                        "a converted bound still bounds the converted payload",
603                        f64::from(declared),
604                        f64::from(observed),
605                    ));
606                }
607            }
608            converted_pointers.insert(pointer);
609        }
610    }
611    Ok(())
612}
613
614/// Buffer bytes outside the converted ranges are identical, counted as
615/// maximal preserved ranges.
616pub(super) fn check_preserved_bytes(
617    source_buffers: &[Vec<u8>],
618    artifact_buffers: &[Vec<u8>],
619    spans: &[AccessorSpan],
620) -> Result<usize, GltfScaleRewriteError> {
621    if source_buffers.len() != artifact_buffers.len() {
622        return Err(failed(
623            "the artifact declares the same number of resolved buffers",
624            artifact_buffers.len() as f64,
625            source_buffers.len() as f64,
626        ));
627    }
628    let mut preserved = 0usize;
629    for (buffer_index, (before, after)) in source_buffers.iter().zip(artifact_buffers).enumerate() {
630        if before.len() != after.len() {
631            return Err(failed(
632                "every resolved buffer keeps its source byte length",
633                after.len() as f64,
634                before.len() as f64,
635            ));
636        }
637        let mut converted: Vec<(usize, usize)> = spans
638            .iter()
639            .filter(|span| span.buffer == buffer_index)
640            .map(|span| (span.start, span.end))
641            .collect();
642        converted.sort_unstable();
643        let mut cursor = 0usize;
644        for (start, end) in converted.into_iter().chain([(before.len(), before.len())]) {
645            if cursor < start {
646                if before[cursor..start] != after[cursor..start] {
647                    return Err(failed(
648                        "buffer bytes outside the converted ranges are preserved",
649                        cursor as f64,
650                        start as f64,
651                    ));
652                }
653                preserved += 1;
654            }
655            // `max` rather than a plain assignment. Unreachable through
656            // `prove_rewritten_artifact` — `converted` is sorted and #280
657            // rejects a source whose scale-bearing accessor ranges overlap,
658            // so no later span can end before the cursor — but a nested span
659            // must never rewind the cursor and re-compare bytes that were
660            // already accounted for. Pinned by
661            // `a_nested_converted_range_does_not_rewind_the_preserved_byte_cursor`.
662            cursor = cursor.max(end);
663        }
664    }
665    Ok(preserved)
666}
667
668#[derive(Debug, Default)]
669struct RawJsonDifferenceCollector {
670    differences: Vec<GltfRawJsonDifference>,
671    total: usize,
672}
673
674impl RawJsonDifferenceCollector {
675    fn record(&mut self, pointer: String, kind: GltfRawJsonDifferenceKind) {
676        self.total += 1;
677        if self.differences.len() < MAX_RAW_JSON_DIFFERENCES {
678            self.differences
679                .push(GltfRawJsonDifference { pointer, kind });
680        }
681    }
682
683    fn finish(self) -> GltfRawJsonDifferenceSummary {
684        let omitted = self.total - self.differences.len();
685        GltfRawJsonDifferenceSummary {
686            differences: self.differences,
687            omitted,
688        }
689    }
690}
691
692/// Check that every raw JSON location outside `allowed` is preserved.
693pub(super) fn check_preserved_json(
694    before: &Value,
695    after: &Value,
696    allowed: &BTreeSet<String>,
697    claim: &'static str,
698) -> Result<(), GltfScaleRewriteError> {
699    let mut collector = RawJsonDifferenceCollector::default();
700    collect_json_differences(before, after, "", allowed, &mut collector);
701    if collector.total == 0 {
702        return Ok(());
703    }
704    let total = collector.total;
705    let summary = collector.finish();
706    Err(GltfScaleRewriteError::ArtifactProofFailed {
707        claim,
708        observed: total as f64,
709        tolerance: 0.0,
710        raw_json_differences: Some(summary),
711    })
712}
713
714/// Collect every JSON location where the artifact differs from the source,
715/// skipping the pointers the conversion is allowed to change.
716fn collect_json_differences(
717    before: &Value,
718    after: &Value,
719    pointer: &str,
720    allowed: &BTreeSet<String>,
721    out: &mut RawJsonDifferenceCollector,
722) {
723    if allowed.contains(pointer) {
724        return;
725    }
726    match (before, after) {
727        (Value::Object(before), Value::Object(after)) => {
728            let keys: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
729            for key in keys {
730                let child = format!("{pointer}/{}", key.replace('~', "~0").replace('/', "~1"));
731                match (before.get(key), after.get(key)) {
732                    (Some(before), Some(after)) => {
733                        collect_json_differences(before, after, &child, allowed, out);
734                    }
735                    // A member only one side declares is a difference unless
736                    // the caller's own scan already accounted for it. The
737                    // rest/bind reparameterization materializes exactly one
738                    // such member — the closure root's `scale`, whose glTF
739                    // default `[1, 1, 1]` is not fixed under `* 1/s` — and its
740                    // caller records that pointer only after checking the
741                    // materialized value against that default. Skipping the
742                    // `allowed` test here would make an added member
743                    // unreportable *and* unchecked; the whole-document
744                    // conversion adds no member at all, so nothing there
745                    // changes either way.
746                    _ if allowed.contains(&child) => {}
747                    (None, Some(_)) => {
748                        out.record(child, GltfRawJsonDifferenceKind::ArtifactAdded);
749                    }
750                    (Some(_), None) => {
751                        out.record(child, GltfRawJsonDifferenceKind::ArtifactRemoved);
752                    }
753                    (None, None) => unreachable!("a key came from at least one object"),
754                }
755            }
756        }
757        (Value::Array(before), Value::Array(after)) if before.len() == after.len() => {
758            for (index, (before, after)) in before.iter().zip(after).enumerate() {
759                collect_json_differences(
760                    before,
761                    after,
762                    &format!("{pointer}/{index}"),
763                    allowed,
764                    out,
765                );
766            }
767        }
768        (Value::Number(before), Value::Number(after)) => {
769            if !json_numbers_have_identical_value_and_zero_sign(before, after) {
770                out.record(pointer.to_owned(), GltfRawJsonDifferenceKind::ValueChanged);
771            }
772        }
773        (before, after) if before == after => {}
774        _ => out.record(pointer.to_owned(), GltfRawJsonDifferenceKind::ValueChanged),
775    }
776}
777
778/// JSON number equality with the authored sign bit preserved for zero.
779///
780/// `serde_json::Number` follows ordinary floating-point equality, under which
781/// `-0.0 == 0.0`. Raw glTF preservation is stricter: a writer may not
782/// canonicalize an untouched authored zero merely because its numeric value is
783/// unchanged.
784fn json_numbers_have_identical_value_and_zero_sign(
785    before: &serde_json::Number,
786    after: &serde_json::Number,
787) -> bool {
788    if before != after {
789        return false;
790    }
791    match (before.as_f64(), after.as_f64()) {
792        (Some(before), Some(after)) if before == 0.0 && after == 0.0 => {
793            before.to_bits() == after.to_bits()
794        }
795        _ => true,
796    }
797}
798
799// --- Independent domain scan ------------------------------------------------
800
801/// Every accessor a whole-document conversion must convert, selected from the
802/// shared structural inventory with a proof-owned disposition interpretation.
803/// The map is keyed by unique accessor index because a `POSITION` shared by
804/// two primitives is one conversion, not `q^2`.
805fn scale_bearing_accessors(
806    plan: &GltfScalePlan,
807    factor_changes: bool,
808) -> Result<BTreeMap<usize, ProofAccessorRule>, GltfScaleRewriteError> {
809    let mut out = BTreeMap::new();
810    for binding in plan.accessor_bindings() {
811        let rule = match &binding.target {
812            RawAccessorTarget::MeshPositions { disposition } => {
813                validate_proof_whole_document_disposition(*disposition, factor_changes)?
814                    .then_some(ProofAccessorRule::AllComponents)
815            }
816            RawAccessorTarget::MorphPositions => {
817                factor_changes.then_some(ProofAccessorRule::AllComponents)
818            }
819            RawAccessorTarget::InstanceInverseBind { source_skin_index } => {
820                let skin = plan.skin_binding(*source_skin_index)?;
821                let mut rewrite = None;
822                for slot in &skin.slots {
823                    let slot_rewrite = validate_proof_whole_document_disposition(
824                        slot.disposition
825                            .ok_or_else(|| plan_mismatch("inverse_bind_disposition_missing"))?,
826                        factor_changes,
827                    )?;
828                    match rewrite {
829                        Some(previous) if previous != slot_rewrite => {
830                            return Err(plan_mismatch("mixed_whole_document_accessor_disposition"));
831                        }
832                        Some(_) => {}
833                        None => rewrite = Some(slot_rewrite),
834                    }
835                }
836                rewrite
837                    .unwrap_or(false)
838                    .then_some(ProofAccessorRule::Mat4TranslationColumn)
839            }
840            RawAccessorTarget::Animation {
841                property: Property::Translation,
842                disposition,
843                ..
844            } => validate_proof_whole_document_disposition(*disposition, factor_changes)?
845                .then_some(ProofAccessorRule::AllComponents),
846            RawAccessorTarget::MeshNormals { .. } => None,
847            RawAccessorTarget::PreserveExact | RawAccessorTarget::Animation { .. } => None,
848        };
849        if let Some(rule) = rule {
850            out.insert(binding.accessor_index, rule);
851        }
852    }
853    Ok(out)
854}
855
856/// Proof-owned interpretation of the whole-document structural rule.
857///
858/// Kept separate from the writer's identical match so sharing the field
859/// vocabulary cannot make the proof repeat a writer selection defect.
860fn validate_proof_whole_document_disposition(
861    disposition: ScaleFieldDisposition,
862    factor_changes: bool,
863) -> Result<bool, GltfScaleRewriteError> {
864    match (factor_changes, disposition) {
865        (true, ScaleFieldDisposition::Rewrite(ScaleRewriteRule::WholeDocumentLength)) => Ok(true),
866        (false, ScaleFieldDisposition::PreserveExact) => Ok(false),
867        _ => Err(plan_mismatch("invalid_whole_document_field_disposition")),
868    }
869}
870
871// --- Residual bookkeeping ---------------------------------------------------
872
873pub(super) fn track_length(
874    before: f64,
875    after: f64,
876    factor: f64,
877    tolerance: &ScaleTolerancePolicy,
878    proof: &mut GltfScaleArtifactProof,
879) -> Result<(), GltfScaleRewriteError> {
880    let expected = before * factor;
881    let residual = (after - expected).abs();
882    proof.length_factor_residual = proof.length_factor_residual.max(residual);
883    let bound = tolerance.scalar_tolerance(expected, after);
884    if residual > bound {
885        return Err(failed(
886            "every converted length differs from the source by exactly the declared factor",
887            residual,
888            bound,
889        ));
890    }
891    Ok(())
892}
893
894pub(super) fn track_dimensionless(
895    before: f64,
896    after: f64,
897    proof: &mut GltfScaleArtifactProof,
898) -> Result<(), GltfScaleRewriteError> {
899    let residual = (after - before).abs();
900    proof.dimensionless_residual = proof.dimensionless_residual.max(residual);
901    if before.to_bits() != after.to_bits() {
902        return Err(failed(
903            "every dimensionless value inside a converted range is invariant",
904            residual,
905            0.0,
906        ));
907    }
908    Ok(())
909}
910
911pub(super) fn failed(claim: &'static str, observed: f64, tolerance: f64) -> GltfScaleRewriteError {
912    GltfScaleRewriteError::ArtifactProofFailed {
913        claim,
914        observed,
915        tolerance,
916        raw_json_differences: None,
917    }
918}
919
920pub(super) fn object(value: &Value) -> Result<&Map<String, Value>, GltfScaleRewriteError> {
921    value
922        .as_object()
923        .ok_or_else(|| LoadError::Malformed("top-level glTF JSON is not an object".into()).into())
924}
925
926pub(super) fn numeric(value: &Value, location: &str) -> Result<f64, GltfScaleRewriteError> {
927    value
928        .as_f64()
929        .ok_or_else(|| LoadError::Malformed(format!("{location} is not a number")).into())
930}
931
932#[cfg(test)]
933mod tests {
934    //! Per-claim negative tests.
935    //!
936    //! Each test corrupts exactly one thing about an otherwise valid artifact
937    //! and asserts the exact claim string that must catch it. These live here
938    //! rather than in `tests/scale_rewrite.rs` because
939    //! [`super::super::GltfScaleArtifact`]'s fields are private, and two of
940    //! the claims — the accessor and JSON-pointer cross-checks — can only be
941    //! falsified by making the artifact's own report disagree with its bytes.
942    //!
943    //! The fixture is a `.gltf`, not a GLB, so an artifact is pure JSON and a
944    //! corruption is a `serde_json` edit plus a base64 round trip. No test
945    //! below asserts a value this crate produced: every expectation is a
946    //! claim string, and every payload literal is exact under a factor of
947    //! four.
948
949    use super::*;
950    use crate::preflight_scale_source_bytes;
951    use animsmith_core::scale::{ScaleOperation, ScaleRequest, plan_scale};
952    use base64::{Engine as _, engine::general_purpose::STANDARD};
953    use serde_json::json;
954
955    /// Byte offsets inside the fixture's single buffer.
956    mod offsets {
957        pub const POSITION: usize = 0; // 36 bytes, converted
958        pub const INVERSE_BIND: usize = 36; // 64 bytes, converted
959        pub const JOINTS: usize = 100; // 24 bytes, preserved
960        pub const WEIGHTS: usize = 124; // 48 bytes, preserved
961        pub const SPARE: usize = 172; // 4 bytes, reached by no bufferView
962        pub const LENGTH: usize = 176;
963    }
964
965    const FACTOR: f64 = 4.0;
966
967    const POSITIONS: [f32; 9] = [1.0, 2.0, -3.0, 0.5, -0.25, 4.0, 2.0, 0.0, 1.5];
968    const INVERSE_BIND: [f32; 16] = [
969        1.0, 0.0, 0.0, 0.0, //
970        0.0, 1.0, 0.0, 0.0, //
971        0.0, 0.0, 1.0, 0.0, //
972        -1.0, 2.0, -0.5, 1.0,
973    ];
974
975    fn data_uri(bytes: &[u8]) -> String {
976        format!(
977            "data:application/octet-stream;base64,{}",
978            STANDARD.encode(bytes)
979        )
980    }
981
982    fn fixture_buffer() -> Vec<u8> {
983        let mut buffer = vec![0u8; offsets::LENGTH];
984        for (index, value) in POSITIONS.iter().enumerate() {
985            let at = offsets::POSITION + index * 4;
986            buffer[at..at + 4].copy_from_slice(&value.to_le_bytes());
987        }
988        for (index, value) in INVERSE_BIND.iter().enumerate() {
989            let at = offsets::INVERSE_BIND + index * 4;
990            buffer[at..at + 4].copy_from_slice(&value.to_le_bytes());
991        }
992        // One joint, full weight on it, for each of the three vertices.
993        for vertex in 0..3 {
994            let at = offsets::WEIGHTS + vertex * 16;
995            buffer[at..at + 4].copy_from_slice(&1.0f32.to_le_bytes());
996        }
997        buffer
998    }
999
1000    /// A skinned source whose buffer carries four bytes no `bufferView`
1001    /// reaches, so a byte can be flipped outside every converted range
1002    /// without disturbing anything the normalized `Document` models.
1003    fn fixture_json(buffer: &[u8]) -> Value {
1004        json!({
1005            "asset": { "version": "2.0" },
1006            "buffers": [{ "uri": data_uri(buffer), "byteLength": offsets::LENGTH }],
1007            "bufferViews": [
1008                { "buffer": 0, "byteOffset": offsets::POSITION, "byteLength": 36 },
1009                { "buffer": 0, "byteOffset": offsets::INVERSE_BIND, "byteLength": 64 },
1010                { "buffer": 0, "byteOffset": offsets::JOINTS, "byteLength": 24 },
1011                { "buffer": 0, "byteOffset": offsets::WEIGHTS, "byteLength": 48 }
1012            ],
1013            "accessors": [
1014                { "bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1015                  "min": [0.5, -0.25, -3.0], "max": [2.0, 2.0, 4.0] },
1016                { "bufferView": 1, "componentType": 5126, "count": 1, "type": "MAT4" },
1017                { "bufferView": 2, "componentType": 5123, "count": 3, "type": "VEC4" },
1018                { "bufferView": 3, "componentType": 5126, "count": 3, "type": "VEC4" }
1019            ],
1020            "materials": [{ "name": "surface" }],
1021            "meshes": [{ "primitives": [{
1022                "attributes": { "POSITION": 0, "JOINTS_0": 2, "WEIGHTS_0": 3 },
1023                "material": 0
1024            }] }],
1025            "nodes": [{ "name": "joint" }, { "name": "holder", "mesh": 0, "skin": 0 }],
1026            "scenes": [{ "nodes": [0, 1] }],
1027            "scene": 0,
1028            "skins": [{ "joints": [0], "skeleton": 0, "inverseBindMatrices": 1 }]
1029        })
1030    }
1031
1032    fn fixture() -> (GltfScaleSource, GltfScaleArtifact, ScalePlan) {
1033        let value = fixture_json(&fixture_buffer());
1034        fixture_from_value(&value)
1035    }
1036
1037    fn fixture_from_value(value: &Value) -> (GltfScaleSource, GltfScaleArtifact, ScalePlan) {
1038        fixture_from_value_with_factor(value, FACTOR)
1039    }
1040
1041    fn fixture_from_value_with_factor(
1042        value: &Value,
1043        factor: f64,
1044    ) -> (GltfScaleSource, GltfScaleArtifact, ScalePlan) {
1045        let bytes = serde_json::to_vec(&value).expect("fixture serializes");
1046        let source = preflight_scale_source_bytes(Path::new("proof-fixture.gltf"), &bytes)
1047            .expect("the fixture preflights cleanly");
1048        let plan = plan_scale(&ScaleRequest {
1049            operation: ScaleOperation::WholeDocumentLinearUnits { factor },
1050            document: source.document(),
1051            capability: &super::super::capability_facts(source.manifest()),
1052        })
1053        .expect("plan");
1054        let artifact = super::super::rewrite_linear_units(&source, factor).expect("rewrite");
1055        (source, artifact, plan)
1056    }
1057
1058    /// The artifact's JSON, as a mutable tree.
1059    fn artifact_value(artifact: &GltfScaleArtifact) -> Value {
1060        serde_json::from_slice(artifact.bytes()).expect("a .gltf artifact is JSON")
1061    }
1062
1063    fn put_artifact_value(artifact: &mut GltfScaleArtifact, value: &Value) {
1064        artifact.bytes = serde_json::to_vec(value).expect("corrupted fixture serializes");
1065    }
1066
1067    fn artifact_buffer(value: &Value) -> Vec<u8> {
1068        let uri = value["buffers"][0]["uri"].as_str().expect("data URI");
1069        STANDARD
1070            .decode(uri.split_once("base64,").expect("base64 data URI").1)
1071            .expect("valid base64")
1072    }
1073
1074    fn put_artifact_buffer(value: &mut Value, bytes: &[u8]) {
1075        value["buffers"][0]["uri"] = json!(data_uri(bytes));
1076    }
1077
1078    /// Assert that proving `artifact` fails with exactly `expected`.
1079    fn expect_claim(
1080        source: &GltfScaleSource,
1081        artifact: &GltfScaleArtifact,
1082        plan: &ScalePlan,
1083        expected: &str,
1084    ) {
1085        match prove_rewritten_artifact(source, artifact, plan) {
1086            Err(GltfScaleRewriteError::ArtifactProofFailed {
1087                claim,
1088                raw_json_differences,
1089                ..
1090            }) => {
1091                assert_eq!(claim, expected);
1092                assert_eq!(
1093                    raw_json_differences, None,
1094                    "ordinary proof claims do not carry raw JSON diagnostics"
1095                );
1096            }
1097            other => panic!("expected the claim {expected:?} to fail, got {other:?}"),
1098        }
1099    }
1100
1101    #[test]
1102    fn the_uncorrupted_fixture_proves_and_reports_its_evidence() {
1103        // Without this, every negative below could be passing for the wrong
1104        // reason: a fixture that never proves cannot show which claim caught
1105        // which corruption.
1106        let (source, artifact, plan) = fixture();
1107        let proof = prove_rewritten_artifact(&source, &artifact, &plan).expect("artifact proof");
1108        assert_eq!(proof.rewritten_accessor_count, 2, "POSITION and the IBM");
1109        assert_eq!(proof.length_factor_residual, 0.0);
1110        assert_eq!(proof.dimensionless_residual, 0.0);
1111        // 0..36 and 36..100 are converted, so 100..104 is the only preserved
1112        // complement range.
1113        assert_eq!(proof.preserved_byte_ranges, 1);
1114        assert_eq!(artifact.rewritten_accessors(), [0, 1]);
1115        assert_eq!(
1116            artifact.rewritten_json_pointers(),
1117            ["/accessors/0/max", "/accessors/0/min"]
1118        );
1119    }
1120
1121    #[test]
1122    fn a_flipped_byte_outside_every_converted_range_fails_byte_preservation() {
1123        let (source, mut artifact, plan) = fixture();
1124        let mut value = artifact_value(&artifact);
1125        let mut buffer = artifact_buffer(&value);
1126        buffer[offsets::SPARE] ^= 0xff;
1127        put_artifact_buffer(&mut value, &buffer);
1128        put_artifact_value(&mut artifact, &value);
1129        expect_claim(
1130            &source,
1131            &artifact,
1132            &plan,
1133            "buffer bytes outside the converted ranges are preserved",
1134        );
1135    }
1136
1137    #[test]
1138    fn a_dimensionless_component_inside_a_converted_range_must_be_bit_identical() {
1139        // The inverse bind's 3x3 entry 1 is `+0.0`; setting its sign bit
1140        // makes it `-0.0`, which is numerically identical and so invisible to
1141        // every residual — only the bit comparison catches it.
1142        let (source, mut artifact, plan) = fixture();
1143        let mut value = artifact_value(&artifact);
1144        let mut buffer = artifact_buffer(&value);
1145        const SIGN_BYTE: usize = offsets::INVERSE_BIND + 4 + 3;
1146        buffer[SIGN_BYTE] |= 0x80;
1147        put_artifact_buffer(&mut value, &buffer);
1148        put_artifact_value(&mut artifact, &value);
1149        expect_claim(
1150            &source,
1151            &artifact,
1152            &plan,
1153            "a converted accessor's dimensionless components are bit-identical",
1154        );
1155    }
1156
1157    #[test]
1158    fn a_dimensionless_node_matrix_component_must_be_exact_in_parsed_f64() {
1159        let mut value = fixture_json(&fixture_buffer());
1160        value["nodes"][0] = json!({
1161            "name": "joint",
1162            "matrix": [
1163                1.0, 0.0, 0.0, 0.0,
1164                0.0, 1.0, 0.0, 0.0,
1165                0.0, 0.0, 1.0, 0.0,
1166                0.0, 0.0, 0.0, 1.0
1167            ]
1168        });
1169        let (source, mut artifact, plan) = fixture_from_value(&value);
1170        let mut doctored = artifact_value(&artifact);
1171        let adjacent = f64::from_bits(1.0f64.to_bits() + 1);
1172        doctored["nodes"][0]["matrix"][0] = json!(adjacent);
1173        put_artifact_value(&mut artifact, &doctored);
1174        let error = prove_rewritten_artifact(&source, &artifact, &plan)
1175            .expect_err("an adjacent identity-multiplier matrix value must be refused");
1176        match error {
1177            GltfScaleRewriteError::ArtifactProofFailed {
1178                claim, observed, ..
1179            } => {
1180                assert_eq!(
1181                    claim,
1182                    "every dimensionless value inside a converted range is invariant"
1183                );
1184                assert_eq!(observed, adjacent - 1.0, "one adjacent matrix f64");
1185            }
1186            other => panic!("expected a dimensionless matrix residual, got {other:?}"),
1187        }
1188    }
1189
1190    #[test]
1191    fn a_factor_one_matrix_translation_is_preserved_by_the_public_artifact_proof() {
1192        let mut value = fixture_json(&fixture_buffer());
1193        let authored = f64::from_bits(1.0f64.to_bits() + 1);
1194        value["nodes"][1]["matrix"] = json!([
1195            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, authored, 0.0, 0.0, 1.0
1196        ]);
1197        let (source, mut artifact, plan) = fixture_from_value_with_factor(&value, 1.0);
1198        assert!(artifact.rewritten_accessors().is_empty());
1199        assert!(artifact.rewritten_json_pointers().is_empty());
1200        assert_eq!(
1201            artifact_value(&artifact)["nodes"][1]["matrix"][12]
1202                .as_f64()
1203                .expect("numeric matrix translation")
1204                .to_bits(),
1205            authored.to_bits()
1206        );
1207        let proof =
1208            prove_rewritten_artifact(&source, &artifact, &plan).expect("factor-one artifact proof");
1209        assert_eq!(proof.rewritten_accessor_count, 0);
1210
1211        let mut doctored = artifact_value(&artifact);
1212        let adjacent = f64::from_bits(authored.to_bits() + 1);
1213        doctored["nodes"][1]["matrix"][12] = json!(adjacent);
1214        put_artifact_value(&mut artifact, &doctored);
1215        let error = prove_rewritten_artifact(&source, &artifact, &plan)
1216            .expect_err("a changed factor-one matrix translation must be refused");
1217        match error {
1218            GltfScaleRewriteError::ArtifactProofFailed {
1219                claim,
1220                observed,
1221                raw_json_differences: Some(summary),
1222                ..
1223            } => {
1224                assert_eq!(
1225                    claim,
1226                    "every raw JSON location outside the converted set is preserved exactly"
1227                );
1228                assert_eq!(observed, 1.0);
1229                assert_eq!(summary.differences.len(), 1);
1230                assert_eq!(summary.differences[0].pointer, "/nodes/1/matrix/12");
1231                assert_eq!(
1232                    summary.differences[0].kind,
1233                    GltfRawJsonDifferenceKind::ValueChanged
1234                );
1235            }
1236            other => panic!("expected a located raw JSON difference, got {other:?}"),
1237        }
1238    }
1239
1240    #[test]
1241    fn an_under_reported_converted_accessor_fails_the_accessor_cross_check() {
1242        let (source, mut artifact, plan) = fixture();
1243        artifact.rewritten_accessors.pop();
1244        expect_claim(
1245            &source,
1246            &artifact,
1247            &plan,
1248            "artifact reports exactly the accessors this proof independently derives",
1249        );
1250    }
1251
1252    #[test]
1253    fn an_under_reported_rewritten_json_pointer_fails_the_pointer_cross_check() {
1254        let (source, mut artifact, plan) = fixture();
1255        artifact.rewritten_json_pointers.pop();
1256        expect_claim(
1257            &source,
1258            &artifact,
1259            &plan,
1260            "artifact reports exactly the JSON pointers this proof independently derives",
1261        );
1262    }
1263
1264    #[test]
1265    fn added_and_removed_preserved_json_locations_keep_whole_document_direction() {
1266        let (source, mut artifact, plan) = fixture();
1267        let mut value = artifact_value(&artifact);
1268        let material = value["materials"][0]
1269            .as_object_mut()
1270            .expect("fixture material is an object");
1271        material.remove("name");
1272        material.insert("replacement".into(), json!(true));
1273        put_artifact_value(&mut artifact, &value);
1274        let error = prove_rewritten_artifact(&source, &artifact, &plan)
1275            .expect_err("changed preserved JSON must fail");
1276        assert_eq!(
1277            error.to_string(),
1278            "artifact proof claim \"every raw JSON location outside the converted set is preserved exactly\" observed 2, tolerance 0; raw JSON differences: /materials/0/name (artifact-removed), /materials/0/replacement (artifact-added)"
1279        );
1280        match error {
1281            GltfScaleRewriteError::ArtifactProofFailed {
1282                claim,
1283                observed,
1284                tolerance,
1285                raw_json_differences: Some(summary),
1286            } => {
1287                assert_eq!(
1288                    claim,
1289                    "every raw JSON location outside the converted set is preserved exactly"
1290                );
1291                assert_eq!(observed, 2.0);
1292                assert_eq!(tolerance, 0.0);
1293                assert_eq!(
1294                    summary,
1295                    GltfRawJsonDifferenceSummary {
1296                        differences: vec![
1297                            GltfRawJsonDifference {
1298                                pointer: "/materials/0/name".into(),
1299                                kind: GltfRawJsonDifferenceKind::ArtifactRemoved,
1300                            },
1301                            GltfRawJsonDifference {
1302                                pointer: "/materials/0/replacement".into(),
1303                                kind: GltfRawJsonDifferenceKind::ArtifactAdded,
1304                            },
1305                        ],
1306                        omitted: 0,
1307                    }
1308                );
1309            }
1310            other => panic!("expected located JSON diagnostics, got {other:?}"),
1311        }
1312    }
1313
1314    #[test]
1315    fn json_difference_collection_is_typed_escaped_ordered_and_allowlisted() {
1316        let before = json!({
1317            "a~/b~/c": 1,
1318            "allowed": "source secret",
1319            "allowedly": 1,
1320            "removed": true,
1321            "value": 1
1322        });
1323        let after = json!({
1324            "a~/b~/c": 2,
1325            "added": true,
1326            "allowed": "artifact secret",
1327            "allowedly": 2,
1328            "value": 2
1329        });
1330        let allowed = BTreeSet::from(["/allowed".to_owned()]);
1331        let mut collector = RawJsonDifferenceCollector::default();
1332        collect_json_differences(&before, &after, "", &allowed, &mut collector);
1333        assert_eq!(
1334            collector.finish(),
1335            GltfRawJsonDifferenceSummary {
1336                differences: vec![
1337                    GltfRawJsonDifference {
1338                        pointer: "/added".into(),
1339                        kind: GltfRawJsonDifferenceKind::ArtifactAdded,
1340                    },
1341                    GltfRawJsonDifference {
1342                        pointer: "/allowedly".into(),
1343                        kind: GltfRawJsonDifferenceKind::ValueChanged,
1344                    },
1345                    GltfRawJsonDifference {
1346                        pointer: "/a~0~1b~0~1c".into(),
1347                        kind: GltfRawJsonDifferenceKind::ValueChanged,
1348                    },
1349                    GltfRawJsonDifference {
1350                        pointer: "/removed".into(),
1351                        kind: GltfRawJsonDifferenceKind::ArtifactRemoved,
1352                    },
1353                    GltfRawJsonDifference {
1354                        pointer: "/value".into(),
1355                        kind: GltfRawJsonDifferenceKind::ValueChanged,
1356                    },
1357                ],
1358                omitted: 0,
1359            }
1360        );
1361    }
1362
1363    #[test]
1364    fn json_difference_collection_preserves_zero_sign_and_quaternion_sign() {
1365        let before: Value = serde_json::from_str(
1366            r#"{"extras":{"authoredZero":-0.0},"nodes":[{"rotation":[0.0,0.0,0.0,-1.0]}]}"#,
1367        )
1368        .expect("source JSON");
1369        let after: Value = serde_json::from_str(
1370            r#"{"extras":{"authoredZero":0.0},"nodes":[{"rotation":[0.0,0.0,0.0,1.0]}]}"#,
1371        )
1372        .expect("artifact JSON");
1373        assert_eq!(
1374            before["extras"]["authoredZero"]
1375                .as_f64()
1376                .expect("source zero")
1377                .to_bits(),
1378            (-0.0f64).to_bits()
1379        );
1380        let mut collector = RawJsonDifferenceCollector::default();
1381        collect_json_differences(&before, &after, "", &BTreeSet::new(), &mut collector);
1382        assert_eq!(
1383            collector.finish(),
1384            GltfRawJsonDifferenceSummary {
1385                differences: vec![
1386                    GltfRawJsonDifference {
1387                        pointer: "/extras/authoredZero".into(),
1388                        kind: GltfRawJsonDifferenceKind::ValueChanged,
1389                    },
1390                    GltfRawJsonDifference {
1391                        pointer: "/nodes/0/rotation/3".into(),
1392                        kind: GltfRawJsonDifferenceKind::ValueChanged,
1393                    },
1394                ],
1395                omitted: 0,
1396            }
1397        );
1398    }
1399
1400    #[test]
1401    fn json_difference_collection_caps_storage_but_counts_every_difference() {
1402        let mut before = Map::new();
1403        let mut after = Map::new();
1404        for index in 0..20 {
1405            let key = format!("key-{index:02}");
1406            match index % 3 {
1407                0 => {
1408                    before.insert(key.clone(), json!(0));
1409                    after.insert(key, json!(1));
1410                }
1411                1 => {
1412                    after.insert(key, json!(1));
1413                }
1414                _ => {
1415                    before.insert(key, json!(0));
1416                }
1417            }
1418        }
1419        let error = check_preserved_json(
1420            &Value::Object(before),
1421            &Value::Object(after),
1422            &BTreeSet::new(),
1423            "the capped preservation fixture stays exact",
1424        )
1425        .expect_err("twenty preserved-location differences must fail");
1426        let GltfScaleRewriteError::ArtifactProofFailed {
1427            claim,
1428            observed,
1429            tolerance,
1430            raw_json_differences: Some(summary),
1431        } = error
1432        else {
1433            panic!("expected located JSON diagnostics, got {error:?}");
1434        };
1435        assert_eq!(claim, "the capped preservation fixture stays exact");
1436        assert_eq!(observed, 20.0, "observed retains the full count");
1437        assert_eq!(tolerance, 0.0);
1438        assert_eq!(summary.differences.len() + summary.omitted, 20);
1439        assert_eq!(summary.omitted, 4);
1440        assert_eq!(
1441            summary.differences,
1442            (0..MAX_RAW_JSON_DIFFERENCES)
1443                .map(|index| GltfRawJsonDifference {
1444                    pointer: format!("/key-{index:02}"),
1445                    kind: match index % 3 {
1446                        0 => GltfRawJsonDifferenceKind::ValueChanged,
1447                        1 => GltfRawJsonDifferenceKind::ArtifactAdded,
1448                        _ => GltfRawJsonDifferenceKind::ArtifactRemoved,
1449                    },
1450                })
1451                .collect::<Vec<_>>()
1452        );
1453        let display = super::super::RawJsonDifferenceSuffix(Some(&summary)).to_string();
1454        assert!(display.ends_with("; 4 omitted"));
1455        assert!(display.contains("/key-15 (value-changed)"));
1456        assert!(
1457            !display.contains("/key-16"),
1458            "omitted pointers stay omitted"
1459        );
1460    }
1461
1462    #[test]
1463    fn unequal_arrays_report_one_value_change_at_the_array_root() {
1464        for (before, after) in [
1465            (json!({ "nodes": [1] }), json!({ "nodes": [1, 2] })),
1466            (json!({ "nodes": [1, 2] }), json!({ "nodes": [1] })),
1467        ] {
1468            let mut collector = RawJsonDifferenceCollector::default();
1469            collect_json_differences(&before, &after, "", &BTreeSet::new(), &mut collector);
1470            assert_eq!(
1471                collector.finish(),
1472                GltfRawJsonDifferenceSummary {
1473                    differences: vec![GltfRawJsonDifference {
1474                        pointer: "/nodes".into(),
1475                        kind: GltfRawJsonDifferenceKind::ValueChanged,
1476                    }],
1477                    omitted: 0,
1478                }
1479            );
1480        }
1481    }
1482
1483    #[test]
1484    fn a_changed_top_level_array_length_fails_array_identity() {
1485        let (source, mut artifact, plan) = fixture();
1486        let mut value = artifact_value(&artifact);
1487        value["materials"]
1488            .as_array_mut()
1489            .expect("materials array")
1490            .push(json!({ "name": "smuggled" }));
1491        put_artifact_value(&mut artifact, &value);
1492        expect_claim(
1493            &source,
1494            &artifact,
1495            &plan,
1496            "every top-level array keeps its source length",
1497        );
1498    }
1499
1500    #[test]
1501    fn a_resolved_buffer_that_grew_fails_the_buffer_length_claim() {
1502        // `byteLength` is left alone, so the growth is invisible to the JSON
1503        // comparison and only the resolved-bytes claim can see it.
1504        let (source, mut artifact, plan) = fixture();
1505        let mut value = artifact_value(&artifact);
1506        let mut buffer = artifact_buffer(&value);
1507        buffer.extend_from_slice(&[0u8; 4]);
1508        put_artifact_buffer(&mut value, &buffer);
1509        put_artifact_value(&mut artifact, &value);
1510        expect_claim(
1511            &source,
1512            &artifact,
1513            &plan,
1514            "every resolved buffer keeps its source byte length",
1515        );
1516    }
1517
1518    #[test]
1519    fn a_declared_buffer_length_without_backing_bytes_fails_container_integrity() {
1520        let (source, mut artifact, plan) = fixture();
1521        let mut value = artifact_value(&artifact);
1522        value["buffers"][0]["byteLength"] = json!(offsets::LENGTH + 4);
1523        put_artifact_value(&mut artifact, &value);
1524        expect_claim(
1525            &source,
1526            &artifact,
1527            &plan,
1528            "every declared buffer byteLength is backed by resolved bytes",
1529        );
1530    }
1531
1532    #[test]
1533    fn a_flipped_container_kind_fails_the_container_claim() {
1534        let (source, mut artifact, plan) = fixture();
1535        artifact.container = GltfContainerKind::Glb;
1536        expect_claim(
1537            &source,
1538            &artifact,
1539            &plan,
1540            "artifact container kind is unchanged",
1541        );
1542    }
1543
1544    #[test]
1545    fn a_bound_that_no_longer_bounds_the_converted_payload_fails() {
1546        // One ULP above the converted minimum: far inside the shared scalar
1547        // tolerance, so every residual still passes and only the bounding
1548        // obligation itself can reject it.
1549        let (source, mut artifact, plan) = fixture();
1550        let mut value = artifact_value(&artifact);
1551        value["accessors"][0]["min"][0] = json!(2.0000002f32 as f64);
1552        put_artifact_value(&mut artifact, &value);
1553        expect_claim(
1554            &source,
1555            &artifact,
1556            &plan,
1557            "a converted bound still bounds the converted payload",
1558        );
1559    }
1560
1561    #[test]
1562    fn bytes_that_are_not_the_rewriters_own_output_fail_the_determinism_claim() {
1563        // Same JSON value, different bytes. Every claim that reads the
1564        // artifact through `serde_json` still passes, so nothing but the
1565        // byte-for-byte repeat comparison can notice.
1566        let (source, mut artifact, plan) = fixture();
1567        let value = artifact_value(&artifact);
1568        artifact.bytes = serde_json::to_vec_pretty(&value).expect("pretty serializes");
1569        expect_claim(
1570            &source,
1571            &artifact,
1572            &plan,
1573            "rewriting the same source twice yields identical bytes",
1574        );
1575    }
1576
1577    #[test]
1578    fn a_nested_converted_range_does_not_rewind_the_preserved_byte_cursor() {
1579        // `check_preserved_bytes` is exercised directly here because #280
1580        // rejects a source whose scale-bearing accessor ranges overlap, so
1581        // `prove_rewritten_artifact` can never hand it a nested pair. The
1582        // guard that makes the walk safe anyway is `cursor.max(end)`: without
1583        // it the inner span rewinds the cursor to 8 and bytes 8..16 — which
1584        // lie *inside* the outer converted range and are legitimately allowed
1585        // to differ — get compared as if they were preserved.
1586        let mut before = vec![0u8; 20];
1587        let mut after = vec![0u8; 20];
1588        before[10] = 1;
1589        after[10] = 2;
1590        let span = |start: usize, end: usize| AccessorSpan {
1591            accessor_index: 0,
1592            buffer: 0,
1593            start,
1594            end,
1595            components: 1,
1596        };
1597        let spans = [span(0, 16), span(4, 8)];
1598        let preserved = check_preserved_bytes(&[before], &[after], &spans)
1599            .expect("only 16..20 lies outside the converted ranges");
1600        assert_eq!(preserved, 1);
1601    }
1602}