Skip to main content

animsmith_engine/
bevy_readback.rs

1//! Closed, engine-neutral contract for the isolated exact-Bevy probe.
2//!
3//! The executable lives outside this workspace because Bevy 0.19 needs Rust
4//! 1.95. This module owns its bounded wire format and imports no Bevy APIs.
5
6use crate::{
7    GltfAddressabilityNamedMapKindV2, GltfAddressabilityProjectionV2, GltfAddressabilityReadbackV2,
8    GltfAnimationCoverageStateV1,
9};
10// Generated from the committed probe lock; see `bevy_readback_lock`.
11use crate::bevy_readback_lock::{BEVY_READBACK_V1_LOCK_BYTES, BEVY_READBACK_V1_LOCK_SHA256};
12use animsmith_core::InputIdentity;
13use serde::{Deserialize, Serialize};
14use std::io::{self, Read, Write};
15
16/// Immutable schema identifier for Bevy readback V1.
17pub const BEVY_READBACK_V1_ID: &str = "urn:animsmith:schema:bevy-readback:1";
18/// Immutable schema version for Bevy readback V1.
19pub const BEVY_READBACK_V1_SCHEMA_VERSION: u32 = 1;
20/// Maximum serialized readback bytes accepted by the strict reader.
21pub const BEVY_READBACK_V1_MAX_REPORT_BYTES: u64 = 16 * 1024 * 1024;
22/// Maximum rows in each observed collection.
23pub const BEVY_READBACK_V1_MAX_ROWS: usize = 4_096;
24/// Maximum aggregate observed rows.
25pub const BEVY_READBACK_V1_MAX_WORK: usize = 65_536;
26/// Maximum `App::update` calls before the harness stops.
27pub const BEVY_READBACK_V1_MAX_UPDATES: u64 = 4_096;
28/// Exact compiler identity required by the isolated harness build script.
29pub const BEVY_READBACK_V1_RUSTC: &str = "rustc 1.95.0 (59807616e 2026-04-14)";
30const MAX_TEXT_BYTES: usize = 1_024;
31
32/// Exact V2 document identity plus its canonical V4 provenance header.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct BevyPredictionReferenceV1 {
36    prediction_input: InputIdentity,
37    provenance_schema: String,
38    provenance_identity: InputIdentity,
39}
40impl BevyPredictionReferenceV1 {
41    /// Construct an identity-only reference; settings and source facts remain in V2.
42    pub fn new(
43        prediction_input: InputIdentity,
44        provenance_schema: String,
45        provenance_identity: InputIdentity,
46    ) -> Self {
47        Self {
48            prediction_input,
49            provenance_schema,
50            provenance_identity,
51        }
52    }
53    /// Exact V2 document bytes.
54    pub const fn prediction_input(&self) -> &InputIdentity {
55        &self.prediction_input
56    }
57    /// V4 provenance schema identifier.
58    pub fn provenance_schema(&self) -> &str {
59        &self.provenance_schema
60    }
61    /// Canonical V4 provenance identity.
62    pub const fn provenance_identity(&self) -> &InputIdentity {
63        &self.provenance_identity
64    }
65}
66
67/// Frozen executable and resolved-lock identity.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct BevyHarnessIdentityV1 {
71    engine: String,
72    engine_version: String,
73    tool_version: String,
74    rust_toolchain: String,
75    bevy_animation_feature: bool,
76    load_animations: bool,
77    lock_identity: InputIdentity,
78    updates: u64,
79}
80impl BevyHarnessIdentityV1 {
81    /// Construct the V1 exact-Bevy harness tuple.
82    pub fn new(
83        tool_version: String,
84        rust_toolchain: String,
85        bevy_animation_feature: bool,
86        load_animations: bool,
87        lock_identity: InputIdentity,
88        updates: u64,
89    ) -> Self {
90        Self {
91            engine: "bevy".into(),
92            engine_version: "0.19.0".into(),
93            tool_version,
94            rust_toolchain,
95            bevy_animation_feature,
96            load_animations,
97            lock_identity,
98            updates,
99        }
100    }
101}
102
103/// One source-indexed Bevy subasset label.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct BevyIndexedLabelV1 {
107    index: u32,
108    label: String,
109}
110impl BevyIndexedLabelV1 {
111    /// Construct an indexed label.
112    pub fn new(index: u32, label: String) -> Self {
113        Self { index, label }
114    }
115    /// Observed source-array index.
116    pub const fn index(&self) -> u32 {
117        self.index
118    }
119}
120
121/// One name-to-source-index winner in Bevy's named map.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct BevyNamedWinnerV1 {
125    name: String,
126    index: u32,
127}
128impl BevyNamedWinnerV1 {
129    /// Construct a named-map winner.
130    pub fn new(name: String, index: u32) -> Self {
131        Self { name, index }
132    }
133    /// Canonical ordering key.
134    pub fn sort_key(&self) -> (&str, u32) {
135        (&self.name, self.index)
136    }
137}
138
139/// One typed `AnimationClip` target ID observed from Bevy.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct BevyAnimationTargetV1 {
143    animation_index: u32,
144    target_id: String,
145}
146impl BevyAnimationTargetV1 {
147    /// Construct one target observation.
148    pub fn new(animation_index: u32, target_id: String) -> Self {
149        Self {
150            animation_index,
151            target_id,
152        }
153    }
154    /// Canonical ordering key.
155    pub fn sort_key(&self) -> (u32, &str) {
156        (self.animation_index, &self.target_id)
157    }
158}
159
160/// Closed, prose-free classification of a public Bevy load error variant.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum BevyLoadErrorCodeV1 {
164    /// Empty asset path.
165    EmptyPath,
166    /// Requested handle had a wrong type.
167    RequestedHandleTypeMismatch,
168    /// No matching loader exists.
169    MissingAssetLoader,
170    /// Extension selected no loader.
171    MissingAssetLoaderForExtension,
172    /// Loader type name is missing.
173    MissingAssetLoaderForTypeName,
174    /// Loader type ID is missing.
175    MissingAssetLoaderForTypeId,
176    /// Asset bytes cannot be read.
177    AssetReader,
178    /// Asset source is unavailable.
179    MissingAssetSource,
180    /// Processed reader is unavailable.
181    MissingProcessedAssetReader,
182    /// Metadata bytes cannot be read.
183    AssetMetadata,
184    /// Metadata cannot be decoded.
185    DeserializeMetadata,
186    /// Asset is processed-only.
187    CannotLoadProcessedAsset,
188    /// Asset is ignored.
189    CannotLoadIgnoredAsset,
190    /// Loader panicked.
191    LoaderPanic,
192    /// Loader returned an opaque error.
193    Loader,
194    /// Async dependency resolution failed.
195    AddAsync,
196    /// A labeled subasset was absent.
197    MissingLabel,
198}
199
200/// Terminal state observed from stock Bevy asset loading.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
203pub enum BevyTerminalStateV1 {
204    /// Root and recursive dependencies reached `Loaded`.
205    Loaded,
206    /// Root asset failure.
207    RootFailure {
208        /// Closed error family only.
209        error: BevyLoadErrorCodeV1,
210    },
211    /// Recursive dependency failure.
212    DependencyFailure {
213        /// Closed error family only.
214        error: BevyLoadErrorCodeV1,
215    },
216    /// The fixed update budget was exhausted.
217    WorkLimit,
218}
219
220/// Bounded redacted tracing metadata, never formatted warning prose.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct BevyWarningV1 {
224    target: String,
225    level: String,
226}
227impl BevyWarningV1 {
228    /// Construct redacted tracing metadata.
229    pub fn new(target: String, level: String) -> Self {
230        Self { target, level }
231    }
232}
233
234/// Typed observations retained from one Bevy load lifecycle.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct BevyObservationV1 {
238    terminal: BevyTerminalStateV1,
239    animations: Vec<BevyIndexedLabelV1>,
240    named_animation_winners: Vec<BevyNamedWinnerV1>,
241    named_scene_winners: Vec<BevyNamedWinnerV1>,
242    named_skin_winners: Vec<BevyNamedWinnerV1>,
243    default_scene: Option<u32>,
244    scenes: Vec<BevyIndexedLabelV1>,
245    nodes: Vec<BevyIndexedLabelV1>,
246    skins: Vec<BevyIndexedLabelV1>,
247    inverse_bind_matrices: Vec<BevyIndexedLabelV1>,
248    targets: Vec<BevyAnimationTargetV1>,
249    warnings: Vec<BevyWarningV1>,
250    warnings_truncated: bool,
251    primary_verified: bool,
252    dependencies_verified: bool,
253}
254impl BevyObservationV1 {
255    /// Construct one observation; V1 validation enforces its bounds and ordering.
256    ///
257    /// The direct arguments intentionally mirror the closed, flat V1 wire
258    /// fields. Grouping them would add a one-use public payload solely to
259    /// silence this lint, without a separate contract meaning.
260    #[allow(clippy::too_many_arguments)]
261    pub fn new(
262        terminal: BevyTerminalStateV1,
263        animations: Vec<BevyIndexedLabelV1>,
264        named_animation_winners: Vec<BevyNamedWinnerV1>,
265        named_scene_winners: Vec<BevyNamedWinnerV1>,
266        named_skin_winners: Vec<BevyNamedWinnerV1>,
267        default_scene: Option<u32>,
268        scenes: Vec<BevyIndexedLabelV1>,
269        nodes: Vec<BevyIndexedLabelV1>,
270        skins: Vec<BevyIndexedLabelV1>,
271        inverse_bind_matrices: Vec<BevyIndexedLabelV1>,
272        targets: Vec<BevyAnimationTargetV1>,
273        warnings: Vec<BevyWarningV1>,
274        warnings_truncated: bool,
275        primary_verified: bool,
276        dependencies_verified: bool,
277    ) -> Self {
278        Self {
279            terminal,
280            animations,
281            named_animation_winners,
282            named_scene_winners,
283            named_skin_winners,
284            default_scene,
285            scenes,
286            nodes,
287            skins,
288            inverse_bind_matrices,
289            targets,
290            warnings,
291            warnings_truncated,
292            primary_verified,
293            dependencies_verified,
294        }
295    }
296    /// Whether one or more warning events arrived after the bounded capture cap.
297    pub const fn warnings_truncated(&self) -> bool {
298        self.warnings_truncated
299    }
300}
301
302/// A closed reason prediction and observation differ.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum BevyConformanceCodeV1 {
306    /// Observed primary bytes do not match V2.
307    InputIdentityMismatch,
308    /// Strict V2 document differs from its readback reference.
309    PredictionDocumentMismatch,
310    /// Exact Bevy revision-3 adapter is absent.
311    BevyAdapterUnavailable,
312    /// V4 provenance header differs.
313    ProvenanceMismatch,
314    /// Closure verification did not succeed.
315    DependencyIdentityMismatch,
316    /// A required prediction facet is unavailable.
317    RequiredPredictionUnavailable,
318    /// Loading did not reach `Loaded`.
319    LoadDidNotSucceed,
320    /// The compiled feature or loader setting differs.
321    SettingsMismatch,
322    /// Typed inventory differs.
323    InventoryMismatch,
324    /// Typed scene inventory differs.
325    SceneMismatch,
326    /// Default-scene route differs.
327    DefaultSceneMismatch,
328    /// Typed skin or inverse-bind inventory differs.
329    SkinMismatch,
330    /// Named winner differs.
331    NamedWinnerMismatch,
332    /// Animation target differs.
333    TargetMismatch,
334}
335
336/// Result of exact comparison with the independently strict-read V2 report.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
339pub enum BevyConformanceV1 {
340    /// Every applicable available fact agreed.
341    Exact,
342    /// One or more facts differed or could not be predicted.
343    NotExact {
344        /// Canonically ordered concrete disagreement reasons.
345        mismatch_codes: Vec<BevyConformanceCodeV1>,
346        /// Canonically ordered unavailable prediction reasons.
347        unavailable_codes: Vec<BevyConformanceCodeV1>,
348    },
349}
350
351/// Closed standalone root with a canonical self-identity.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct BevyReadbackV1 {
355    schema_version: u32,
356    schema: String,
357    identity: InputIdentity,
358    harness: BevyHarnessIdentityV1,
359    input: InputIdentity,
360    prediction: BevyPredictionReferenceV1,
361    observation: BevyObservationV1,
362    conformance: BevyConformanceV1,
363}
364impl BevyReadbackV1 {
365    /// Construct V1 and calculate its canonical self-identity.
366    pub fn new(
367        harness: BevyHarnessIdentityV1,
368        input: InputIdentity,
369        prediction: BevyPredictionReferenceV1,
370        observation: BevyObservationV1,
371        conformance: BevyConformanceV1,
372    ) -> Result<Self, BevyReadbackV1Error> {
373        let mut value = Self {
374            schema_version: 1,
375            schema: BEVY_READBACK_V1_ID.into(),
376            identity: InputIdentity::from_bytes(&[]),
377            harness,
378            input,
379            prediction,
380            observation,
381            conformance,
382        };
383        value.identity = value.computed_identity()?;
384        value.validate()?;
385        Ok(value)
386    }
387    /// Exact asset identity actually read before loading.
388    pub const fn input(&self) -> &InputIdentity {
389        &self.input
390    }
391    /// Typed engine observations.
392    pub const fn observation(&self) -> &BevyObservationV1 {
393        &self.observation
394    }
395    /// Comparison result.
396    pub const fn conformance(&self) -> &BevyConformanceV1 {
397        &self.conformance
398    }
399    fn computed_identity(&self) -> Result<InputIdentity, BevyReadbackV1Error> {
400        bounded_jcs(&Seed {
401            schema_version: self.schema_version,
402            schema: &self.schema,
403            harness: &self.harness,
404            input: &self.input,
405            prediction: &self.prediction,
406            observation: &self.observation,
407            conformance: &self.conformance,
408        })
409        .map(|bytes| InputIdentity::from_bytes(&bytes))
410    }
411    /// Validate fixed tuple, canonical identity, bounds, and closed state.
412    pub fn validate(&self) -> Result<(), BevyReadbackV1Error> {
413        if self.schema_version != 1 || self.schema != BEVY_READBACK_V1_ID {
414            return Err(BevyReadbackV1Error::Contract("invalid schema header"));
415        }
416        let h = &self.harness;
417        if h.engine != "bevy"
418            || h.engine_version != "0.19.0"
419            || !safe(&h.tool_version)
420            || h.rust_toolchain != BEVY_READBACK_V1_RUSTC
421            || !h.bevy_animation_feature
422            || !h.load_animations
423            || h.lock_identity.bytes() != BEVY_READBACK_V1_LOCK_BYTES
424            || h.lock_identity.sha256() != BEVY_READBACK_V1_LOCK_SHA256
425            || h.updates > BEVY_READBACK_V1_MAX_UPDATES
426        {
427            return Err(BevyReadbackV1Error::Contract(
428                "invalid frozen harness tuple",
429            ));
430        }
431        if self.prediction.provenance_schema != animsmith_core::PREDICTION_PROVENANCE_V4_ID {
432            return Err(BevyReadbackV1Error::Contract(
433                "invalid V4 provenance header",
434            ));
435        }
436        validate_observation(&self.observation)?;
437        validate_conformance(&self.conformance)?;
438        if matches!(self.conformance, BevyConformanceV1::Exact)
439            && (!matches!(self.observation.terminal, BevyTerminalStateV1::Loaded)
440                || !self.observation.primary_verified
441                || !self.observation.dependencies_verified)
442        {
443            return Err(BevyReadbackV1Error::Contract(
444                "exact conformance requires a verified loaded observation",
445            ));
446        }
447        if self.identity != self.computed_identity()? {
448            return Err(BevyReadbackV1Error::Contract("self identity mismatch"));
449        }
450        bounded_jcs(self)?;
451        Ok(())
452    }
453}
454#[derive(Serialize)]
455struct Seed<'a> {
456    schema_version: u32,
457    schema: &'a str,
458    harness: &'a BevyHarnessIdentityV1,
459    input: &'a InputIdentity,
460    prediction: &'a BevyPredictionReferenceV1,
461    observation: &'a BevyObservationV1,
462    conformance: &'a BevyConformanceV1,
463}
464
465fn bounded_jcs(value: &impl Serialize) -> Result<Vec<u8>, BevyReadbackV1Error> {
466    bounded_jcs_with_limit(value, BEVY_READBACK_V1_MAX_REPORT_BYTES as usize)
467}
468
469fn bounded_jcs_with_limit(
470    value: &impl Serialize,
471    limit: usize,
472) -> Result<Vec<u8>, BevyReadbackV1Error> {
473    let mut writer = BoundedJcsWriter {
474        bytes: Vec::new(),
475        limit,
476        overflowed: false,
477    };
478    serde_jcs::to_writer(&mut writer, value).map_err(|error| {
479        if writer.overflowed {
480            BevyReadbackV1Error::CanonicalTooLarge {
481                limit: limit as u64,
482            }
483        } else {
484            BevyReadbackV1Error::Canonical(error)
485        }
486    })?;
487    Ok(writer.bytes)
488}
489
490struct BoundedJcsWriter {
491    bytes: Vec<u8>,
492    limit: usize,
493    overflowed: bool,
494}
495
496impl Write for BoundedJcsWriter {
497    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
498        let Some(length) = self.bytes.len().checked_add(bytes.len()) else {
499            self.overflowed = true;
500            return Err(io::Error::other("Bevy readback canonical limit"));
501        };
502        if length > self.limit {
503            self.overflowed = true;
504            return Err(io::Error::other("Bevy readback canonical limit"));
505        }
506        self.bytes.extend_from_slice(bytes);
507        Ok(bytes.len())
508    }
509
510    fn flush(&mut self) -> io::Result<()> {
511        Ok(())
512    }
513}
514
515/// Bounded strict-reader failure for Bevy readback V1.
516#[derive(Debug, thiserror::Error)]
517#[non_exhaustive]
518pub enum BevyReadbackV1Error {
519    /// Input stream cannot be read.
520    #[error("cannot read Bevy readback: {0}")]
521    Io(#[from] std::io::Error),
522    /// Input exceeds the immutable limit.
523    #[error("Bevy readback exceeds byte limit {limit}")]
524    ReportTooLarge {
525        /// Immutable byte limit.
526        limit: u64,
527    },
528    /// JSON shape is not closed V1.
529    #[error("invalid Bevy readback JSON: {0}")]
530    Json(#[from] serde_json::Error),
531    /// Canonical serializer failed.
532    #[error("cannot canonicalize Bevy readback: {0}")]
533    Canonical(serde_json::Error),
534    /// Canonical V1 bytes exceed the same cap as the strict reader.
535    #[error("Bevy readback canonical bytes exceed byte limit {limit}")]
536    CanonicalTooLarge {
537        /// Immutable byte limit.
538        limit: u64,
539    },
540    /// A semantic invariant failed.
541    #[error("invalid Bevy readback V1 contract: {0}")]
542    Contract(&'static str),
543}
544
545/// Strictly read one complete bounded V1 document.
546pub fn validate_bevy_readback_v1(reader: impl Read) -> Result<BevyReadbackV1, BevyReadbackV1Error> {
547    let mut bytes = Vec::new();
548    reader
549        .take(BEVY_READBACK_V1_MAX_REPORT_BYTES + 1)
550        .read_to_end(&mut bytes)?;
551    if bytes.len() as u64 > BEVY_READBACK_V1_MAX_REPORT_BYTES {
552        return Err(BevyReadbackV1Error::ReportTooLarge {
553            limit: BEVY_READBACK_V1_MAX_REPORT_BYTES,
554        });
555    }
556    let value: BevyReadbackV1 = serde_json::from_slice(&bytes)?;
557    value.validate()?;
558    Ok(value)
559}
560
561/// Verify a stored V1 result against its independently strict-read V2 input.
562pub fn validate_bevy_readback_prediction_v1(
563    readback: &BevyReadbackV1,
564    prediction: &GltfAddressabilityReadbackV2,
565    prediction_input: &InputIdentity,
566) -> Result<(), BevyReadbackV1Error> {
567    readback.validate()?;
568    if compare_bevy_readback_v1(readback, prediction, prediction_input) != readback.conformance {
569        return Err(BevyReadbackV1Error::Contract(
570            "stored conformance differs from recomputation",
571        ));
572    }
573    Ok(())
574}
575
576/// Compare a V1 readback with an independently strict-read V2 prediction.
577pub fn compare_bevy_readback_v1(
578    readback: &BevyReadbackV1,
579    prediction: &GltfAddressabilityReadbackV2,
580    prediction_input: &InputIdentity,
581) -> BevyConformanceV1 {
582    let mut mismatch = Vec::new();
583    let mut unavailable = Vec::new();
584    if readback.input != *prediction.input() || !readback.observation.primary_verified {
585        mismatch.push(BevyConformanceCodeV1::InputIdentityMismatch);
586    }
587    if readback.prediction.prediction_input != *prediction_input {
588        mismatch.push(BevyConformanceCodeV1::PredictionDocumentMismatch);
589    }
590    let Some(adapter) = prediction.bevy() else {
591        return BevyConformanceV1::NotExact {
592            mismatch_codes: vec![BevyConformanceCodeV1::BevyAdapterUnavailable],
593            unavailable_codes: Vec::new(),
594        };
595    };
596    let provenance = adapter.prediction_provenance();
597    if readback.prediction.provenance_schema != provenance.contract_id()
598        || readback.prediction.provenance_identity != *provenance.identity().input_identity()
599    {
600        mismatch.push(BevyConformanceCodeV1::ProvenanceMismatch);
601    }
602    if !readback.observation.dependencies_verified {
603        mismatch.push(BevyConformanceCodeV1::DependencyIdentityMismatch);
604    }
605    if !matches!(readback.observation.terminal, BevyTerminalStateV1::Loaded) {
606        mismatch.push(BevyConformanceCodeV1::LoadDidNotSucceed);
607    }
608    let projection = adapter.projection();
609    if readback.harness.bevy_animation_feature != adapter.settings().bevy_animation_feature()
610        || readback.harness.load_animations != adapter.settings().load_animations()
611    {
612        mismatch.push(BevyConformanceCodeV1::SettingsMismatch);
613    }
614    let animations_available = prediction
615        .inventory()
616        .animations()
617        .animations()
618        .coverage()
619        .state()
620        == GltfAnimationCoverageStateV1::Complete;
621    let expected_animations = prediction
622        .inventory()
623        .animations()
624        .animations()
625        .rows()
626        .iter()
627        .map(|row| {
628            BevyIndexedLabelV1::new(
629                row.source_clip_index() as u32,
630                format!("Animation{}", row.source_clip_index()),
631            )
632        })
633        .collect::<Vec<_>>();
634    compare_available_prediction_facet(
635        animations_available.then_some(expected_animations),
636        &readback.observation.animations,
637        BevyConformanceCodeV1::InventoryMismatch,
638        &mut mismatch,
639        &mut unavailable,
640    );
641    let raw = prediction.inventory().raw();
642    if raw.scene_coverage().is_complete() {
643        let expected = raw
644            .scenes()
645            .iter()
646            .map(|row| {
647                BevyIndexedLabelV1::new(
648                    row.source_scene_index() as u32,
649                    format!("Scene{}", row.source_scene_index()),
650                )
651            })
652            .collect::<Vec<_>>();
653        if expected != readback.observation.scenes {
654            mismatch.push(BevyConformanceCodeV1::SceneMismatch);
655        }
656    } else {
657        unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable);
658    }
659    if raw.node_coverage().is_complete() {
660        let expected = raw
661            .nodes()
662            .iter()
663            .map(|row| {
664                BevyIndexedLabelV1::new(
665                    row.source_node_index() as u32,
666                    format!("Node{}", row.source_node_index()),
667                )
668            })
669            .collect::<Vec<_>>();
670        if expected != readback.observation.nodes {
671            mismatch.push(BevyConformanceCodeV1::InventoryMismatch);
672        }
673    } else {
674        unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable);
675    }
676    let mut expected_skins = Vec::new();
677    let mut skins_available = true;
678    for skin in projection.skins() {
679        match skin.skin_label() {
680            GltfAddressabilityProjectionV2::Available { value } => {
681                expected_skins.push(BevyIndexedLabelV1::new(
682                    skin.source_skin_index() as u32,
683                    value.clone(),
684                ));
685            }
686            GltfAddressabilityProjectionV2::ProvenAbsent => {}
687            GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
688                skins_available = false;
689            }
690        }
691    }
692    compare_available_prediction_facet(
693        skins_available.then_some(expected_skins),
694        &readback.observation.skins,
695        BevyConformanceCodeV1::SkinMismatch,
696        &mut mismatch,
697        &mut unavailable,
698    );
699    match projection.default_scene_route() {
700        GltfAddressabilityProjectionV2::Available { value } => {
701            if parse_label_index(value, "Scene") != readback.observation.default_scene {
702                mismatch.push(BevyConformanceCodeV1::DefaultSceneMismatch);
703            }
704        }
705        GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
706            unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable)
707        }
708        GltfAddressabilityProjectionV2::ProvenAbsent => {
709            if readback.observation.default_scene.is_some() {
710                mismatch.push(BevyConformanceCodeV1::DefaultSceneMismatch);
711            }
712        }
713    }
714    let expected_inverse = projection
715        .skins()
716        .iter()
717        // V3 makes inverse-bind labels eager for every source skin, even
718        // when its conditional `Skin{i}` asset label is absent because no
719        // node attaches that skin.
720        .map(|skin| {
721            BevyIndexedLabelV1::new(
722                skin.source_skin_index() as u32,
723                skin.inverse_bind_matrices_label().into(),
724            )
725        })
726        .collect::<Vec<_>>();
727    if expected_inverse != readback.observation.inverse_bind_matrices {
728        mismatch.push(BevyConformanceCodeV1::SkinMismatch);
729    }
730    let expected_named = projection
731        .named_maps()
732        .iter()
733        .find(|map| map.kind() == GltfAddressabilityNamedMapKindV2::Animation)
734        .and_then(|map| match map.winners() {
735            GltfAddressabilityProjectionV2::Available { value } => Some(
736                value
737                    .iter()
738                    .map(|row| BevyNamedWinnerV1::new(row.name().into(), row.source_index() as u32))
739                    .collect::<Vec<_>>(),
740            ),
741            GltfAddressabilityProjectionV2::ProvenAbsent => Some(Vec::new()),
742            GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => None,
743        });
744    match expected_named {
745        Some(expected) if expected != readback.observation.named_animation_winners => {
746            mismatch.push(BevyConformanceCodeV1::NamedWinnerMismatch)
747        }
748        None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
749        _ => {}
750    }
751    for (kind, observed) in [
752        (
753            GltfAddressabilityNamedMapKindV2::Scene,
754            &readback.observation.named_scene_winners,
755        ),
756        (
757            GltfAddressabilityNamedMapKindV2::Skin,
758            &readback.observation.named_skin_winners,
759        ),
760    ] {
761        let expected = projection
762            .named_maps()
763            .iter()
764            .find(|map| map.kind() == kind)
765            .and_then(|map| match map.winners() {
766                GltfAddressabilityProjectionV2::Available { value } => Some(
767                    value
768                        .iter()
769                        .map(|row| {
770                            BevyNamedWinnerV1::new(row.name().into(), row.source_index() as u32)
771                        })
772                        .collect::<Vec<_>>(),
773                ),
774                GltfAddressabilityProjectionV2::ProvenAbsent => Some(Vec::new()),
775                GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => None,
776            });
777        match expected {
778            Some(expected) if expected != *observed => {
779                mismatch.push(BevyConformanceCodeV1::NamedWinnerMismatch)
780            }
781            None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
782            _ => {}
783        }
784    }
785    let mut expected_targets = Vec::new();
786    let mut targets_available = !matches!(
787        projection.target_coverage(),
788        GltfAddressabilityProjectionV2::RequiredUnavailable { .. }
789    );
790    for target in projection.targets() {
791        match target.projection() {
792            GltfAddressabilityProjectionV2::Available { value } => {
793                for channel in target.contributing_channels() {
794                    expected_targets.push(BevyAnimationTargetV1::new(
795                        channel.source_animation_index() as u32,
796                        value.uuid().into(),
797                    ));
798                }
799            }
800            GltfAddressabilityProjectionV2::RequiredUnavailable { .. } => {
801                targets_available = false;
802            }
803            GltfAddressabilityProjectionV2::ProvenAbsent => {}
804        }
805    }
806    expected_targets
807        .sort_by(|a, b| (a.animation_index, &a.target_id).cmp(&(b.animation_index, &b.target_id)));
808    expected_targets.dedup();
809    compare_available_prediction_facet(
810        targets_available.then_some(expected_targets),
811        &readback.observation.targets,
812        BevyConformanceCodeV1::TargetMismatch,
813        &mut mismatch,
814        &mut unavailable,
815    );
816    mismatch.sort();
817    mismatch.dedup();
818    unavailable.sort();
819    unavailable.dedup();
820    if mismatch.is_empty() && unavailable.is_empty() {
821        BevyConformanceV1::Exact
822    } else {
823        BevyConformanceV1::NotExact {
824            mismatch_codes: mismatch,
825            unavailable_codes: unavailable,
826        }
827    }
828}
829
830fn compare_available_prediction_facet<T: PartialEq>(
831    expected: Option<T>,
832    observed: &T,
833    mismatch_code: BevyConformanceCodeV1,
834    mismatch: &mut Vec<BevyConformanceCodeV1>,
835    unavailable: &mut Vec<BevyConformanceCodeV1>,
836) {
837    match expected {
838        Some(expected) if expected != *observed => mismatch.push(mismatch_code),
839        Some(_) => {}
840        None => unavailable.push(BevyConformanceCodeV1::RequiredPredictionUnavailable),
841    }
842}
843
844fn parse_label_index(value: &str, prefix: &str) -> Option<u32> {
845    value.strip_prefix(prefix)?.parse().ok()
846}
847
848fn validate_observation(value: &BevyObservationV1) -> Result<(), BevyReadbackV1Error> {
849    if [
850        value.animations.len(),
851        value.named_animation_winners.len(),
852        value.named_scene_winners.len(),
853        value.named_skin_winners.len(),
854        value.scenes.len(),
855        value.nodes.len(),
856        value.skins.len(),
857        value.inverse_bind_matrices.len(),
858        value.targets.len(),
859        value.warnings.len(),
860    ]
861    .iter()
862    .any(|n| *n > BEVY_READBACK_V1_MAX_ROWS)
863    {
864        return Err(BevyReadbackV1Error::Contract("domain row limit"));
865    }
866    if value.animations.len()
867        + value.named_animation_winners.len()
868        + value.named_scene_winners.len()
869        + value.named_skin_winners.len()
870        + value.scenes.len()
871        + value.nodes.len()
872        + value.skins.len()
873        + value.inverse_bind_matrices.len()
874        + value.targets.len()
875        + value.warnings.len()
876        > BEVY_READBACK_V1_MAX_WORK
877    {
878        return Err(BevyReadbackV1Error::Contract("aggregate work limit"));
879    }
880    for rows in [
881        &value.animations,
882        &value.scenes,
883        &value.nodes,
884        &value.skins,
885        &value.inverse_bind_matrices,
886    ] {
887        if !rows.windows(2).all(|p| p[0].index < p[1].index)
888            || rows.iter().any(|row| !safe(&row.label))
889        {
890            return Err(BevyReadbackV1Error::Contract("noncanonical indexed rows"));
891        }
892    }
893    for winners in [
894        &value.named_animation_winners,
895        &value.named_scene_winners,
896        &value.named_skin_winners,
897    ] {
898        if !winners
899            .windows(2)
900            .all(|p| (&p[0].name, p[0].index) < (&p[1].name, p[1].index))
901            || winners.iter().any(|row| !safe(&row.name))
902        {
903            return Err(BevyReadbackV1Error::Contract("noncanonical named winners"));
904        }
905    }
906    if !value
907        .targets
908        .windows(2)
909        .all(|p| (p[0].animation_index, &p[0].target_id) < (p[1].animation_index, &p[1].target_id))
910        || value.targets.iter().any(|row| !safe(&row.target_id))
911    {
912        return Err(BevyReadbackV1Error::Contract("noncanonical targets"));
913    }
914    if !value
915        .warnings
916        .windows(2)
917        .all(|p| (&p[0].target, &p[0].level) < (&p[1].target, &p[1].level))
918        || value
919            .warnings
920            .iter()
921            .any(|row| !safe(&row.target) || !safe(&row.level))
922    {
923        return Err(BevyReadbackV1Error::Contract("noncanonical warnings"));
924    }
925    if !matches!(value.terminal, BevyTerminalStateV1::Loaded)
926        && (!value.animations.is_empty()
927            || !value.named_animation_winners.is_empty()
928            || !value.named_scene_winners.is_empty()
929            || !value.named_skin_winners.is_empty()
930            || value.default_scene.is_some()
931            || !value.scenes.is_empty()
932            || !value.nodes.is_empty()
933            || !value.skins.is_empty()
934            || !value.inverse_bind_matrices.is_empty()
935            || !value.targets.is_empty())
936    {
937        return Err(BevyReadbackV1Error::Contract("failed load has inventory"));
938    }
939    Ok(())
940}
941fn validate_conformance(value: &BevyConformanceV1) -> Result<(), BevyReadbackV1Error> {
942    match value {
943        BevyConformanceV1::Exact => Ok(()),
944        BevyConformanceV1::NotExact {
945            mismatch_codes,
946            unavailable_codes,
947        } if !(mismatch_codes.is_empty() && unavailable_codes.is_empty())
948            && mismatch_codes.windows(2).all(|p| p[0] < p[1])
949            && unavailable_codes.windows(2).all(|p| p[0] < p[1]) =>
950        {
951            Ok(())
952        }
953        _ => Err(BevyReadbackV1Error::Contract("noncanonical conformance")),
954    }
955}
956fn safe(value: &str) -> bool {
957    !value.is_empty() && value.len() <= MAX_TEXT_BYTES && !value.chars().any(char::is_control)
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963    use crate::GltfAddressabilityV2;
964
965    fn frozen_lock_identity() -> InputIdentity {
966        InputIdentity::from_sha256_hex(BEVY_READBACK_V1_LOCK_SHA256, BEVY_READBACK_V1_LOCK_BYTES)
967            .expect("the committed lock identity is a lowercase digest")
968    }
969
970    #[test]
971    fn strict_reader_rejects_the_previous_mixed_bevy_patch_graph_by_digest_or_by_size() {
972        // The previous graph differed from the frozen one in both size and
973        // digest. Each difference alone must reject, so neither half of the
974        // comparison can be dropped and neither half can be inferred from the
975        // other.
976        let previous_digest = "e3457e21695874f90110e5b93a36d7688c134a3b9259cb1dc4d2d8e5f741d1ed";
977        let previous_bytes = 86_390;
978        for (sha256, bytes) in [
979            (previous_digest, previous_bytes),
980            (previous_digest, BEVY_READBACK_V1_LOCK_BYTES),
981            (BEVY_READBACK_V1_LOCK_SHA256, previous_bytes),
982        ] {
983            let mut readback = valid_readback(false);
984            readback.harness.lock_identity = InputIdentity::from_sha256_hex(sha256, bytes)
985                .expect("both digests are lowercase hexadecimal");
986            let encoded = serde_json::to_vec(&readback).unwrap();
987            assert!(
988                matches!(
989                    validate_bevy_readback_v1(encoded.as_slice()),
990                    Err(BevyReadbackV1Error::Contract(
991                        "invalid frozen harness tuple"
992                    ))
993                ),
994                "accepted lock identity {sha256} at {bytes} bytes"
995            );
996        }
997    }
998
999    fn valid_readback(warnings_truncated: bool) -> BevyReadbackV1 {
1000        BevyReadbackV1::new(
1001            BevyHarnessIdentityV1::new(
1002                "0.1.0".into(),
1003                BEVY_READBACK_V1_RUSTC.into(),
1004                true,
1005                true,
1006                frozen_lock_identity(),
1007                1,
1008            ),
1009            InputIdentity::from_bytes(b"asset"),
1010            BevyPredictionReferenceV1::new(
1011                InputIdentity::from_bytes(b"p"),
1012                animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1013                InputIdentity::from_bytes(b"q"),
1014            ),
1015            BevyObservationV1::new(
1016                BevyTerminalStateV1::Loaded,
1017                vec![],
1018                vec![],
1019                vec![],
1020                vec![],
1021                None,
1022                vec![],
1023                vec![],
1024                vec![],
1025                vec![],
1026                vec![],
1027                vec![],
1028                warnings_truncated,
1029                true,
1030                true,
1031            ),
1032            BevyConformanceV1::Exact,
1033        )
1034        .unwrap()
1035    }
1036
1037    #[test]
1038    fn invalid_harness_cannot_form_a_readback() {
1039        let v = BevyReadbackV1::new(
1040            BevyHarnessIdentityV1::new(
1041                "0.1.0".into(),
1042                BEVY_READBACK_V1_RUSTC.into(),
1043                true,
1044                true,
1045                InputIdentity::from_bytes(b"not-the-tool-lock"),
1046                1,
1047            ),
1048            InputIdentity::from_bytes(b"asset"),
1049            BevyPredictionReferenceV1::new(
1050                InputIdentity::from_bytes(b"p"),
1051                animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1052                InputIdentity::from_bytes(b"q"),
1053            ),
1054            BevyObservationV1::new(
1055                BevyTerminalStateV1::Loaded,
1056                vec![],
1057                vec![],
1058                vec![],
1059                vec![],
1060                None,
1061                vec![],
1062                vec![],
1063                vec![],
1064                vec![],
1065                vec![],
1066                vec![],
1067                false,
1068                true,
1069                true,
1070            ),
1071            BevyConformanceV1::Exact,
1072        );
1073        assert!(v.is_err());
1074    }
1075
1076    #[test]
1077    fn warning_truncation_is_identity_bound_and_strictly_read() {
1078        let readback = valid_readback(true);
1079        assert!(readback.observation().warnings_truncated());
1080        let mut value = serde_json::to_value(readback).unwrap();
1081        value["observation"]["warnings_truncated"] = serde_json::Value::Bool(false);
1082        assert!(validate_bevy_readback_v1(serde_json::to_vec(&value).unwrap().as_slice()).is_err());
1083    }
1084
1085    #[test]
1086    fn strict_reader_rejects_mutated_public_contract_fields() {
1087        let source = serde_json::to_value(valid_readback(false)).unwrap();
1088        for (pointer, replacement) in [
1089            ("/schema_version", serde_json::json!(2)),
1090            ("/schema", serde_json::json!("urn:animsmith:schema:other:1")),
1091            ("/harness/engine", serde_json::json!("other")),
1092            ("/harness/tool_version", serde_json::json!("0.1.1")),
1093            ("/harness/rust_toolchain", serde_json::json!("rustc 1.95.0")),
1094            ("/harness/load_animations", serde_json::json!(false)),
1095            ("/harness/lock_identity/sha256", serde_json::json!("00")),
1096            ("/input/sha256", serde_json::json!("00")),
1097            ("/prediction/provenance_schema", serde_json::json!("other")),
1098            (
1099                "/observation/terminal",
1100                serde_json::json!({"state":"work_limit"}),
1101            ),
1102            (
1103                "/observation/animations",
1104                serde_json::json!([{"index": 0, "label": "Animation0"}]),
1105            ),
1106        ] {
1107            let mut changed = source.clone();
1108            *changed.pointer_mut(pointer).unwrap() = replacement;
1109            let bytes = serde_json::to_vec(&changed).unwrap();
1110            assert!(
1111                validate_bevy_readback_v1(bytes.as_slice()).is_err(),
1112                "{pointer}"
1113            );
1114        }
1115        let bytes = serde_json::to_vec(&source).unwrap();
1116        assert!(GltfAddressabilityV2::read_from(bytes.as_slice()).is_err());
1117    }
1118
1119    #[test]
1120    fn strict_reader_then_prediction_recomputation_rejects_wrong_conformance() {
1121        let mut prediction_json = crate::addressability_v2::tests::adapter_report_json(Some(
1122            crate::TargetPointerWidth::Bits64,
1123        ));
1124        prediction_json["bevy"] = serde_json::Value::Null;
1125        let prediction_bytes = serde_json::to_vec(&prediction_json).unwrap();
1126        let prediction_input = InputIdentity::from_bytes(&prediction_bytes);
1127        let prediction = GltfAddressabilityV2::read_from(prediction_bytes.as_slice()).unwrap();
1128        let deliberately_wrong = BevyReadbackV1::new(
1129            BevyHarnessIdentityV1::new(
1130                "0.1.0".into(),
1131                BEVY_READBACK_V1_RUSTC.into(),
1132                true,
1133                true,
1134                frozen_lock_identity(),
1135                1,
1136            ),
1137            prediction.input().clone(),
1138            BevyPredictionReferenceV1::new(
1139                prediction_input.clone(),
1140                animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1141                InputIdentity::from_bytes(b"adapter unavailable has no V4 reference"),
1142            ),
1143            BevyObservationV1::new(
1144                BevyTerminalStateV1::Loaded,
1145                vec![],
1146                vec![],
1147                vec![],
1148                vec![],
1149                None,
1150                vec![],
1151                vec![],
1152                vec![],
1153                vec![],
1154                vec![],
1155                vec![],
1156                false,
1157                true,
1158                true,
1159            ),
1160            BevyConformanceV1::Exact,
1161        )
1162        .unwrap();
1163        let bytes = serde_json::to_vec(&deliberately_wrong).unwrap();
1164        let strict = validate_bevy_readback_v1(bytes.as_slice()).unwrap();
1165        assert_eq!(
1166            compare_bevy_readback_v1(&strict, &prediction, &prediction_input),
1167            BevyConformanceV1::NotExact {
1168                mismatch_codes: vec![BevyConformanceCodeV1::BevyAdapterUnavailable],
1169                unavailable_codes: vec![],
1170            }
1171        );
1172        assert!(
1173            validate_bevy_readback_prediction_v1(&strict, &prediction, &prediction_input).is_err()
1174        );
1175    }
1176
1177    #[test]
1178    fn terminal_failure_readbacks_are_strict_and_non_exact() {
1179        for terminal in [
1180            BevyTerminalStateV1::RootFailure {
1181                error: BevyLoadErrorCodeV1::AssetReader,
1182            },
1183            BevyTerminalStateV1::DependencyFailure {
1184                error: BevyLoadErrorCodeV1::AssetReader,
1185            },
1186        ] {
1187            let readback = BevyReadbackV1::new(
1188                BevyHarnessIdentityV1::new(
1189                    "0.1.0".into(),
1190                    BEVY_READBACK_V1_RUSTC.into(),
1191                    true,
1192                    true,
1193                    frozen_lock_identity(),
1194                    1,
1195                ),
1196                InputIdentity::from_bytes(b"asset"),
1197                BevyPredictionReferenceV1::new(
1198                    InputIdentity::from_bytes(b"p"),
1199                    animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1200                    InputIdentity::from_bytes(b"q"),
1201                ),
1202                BevyObservationV1::new(
1203                    terminal,
1204                    vec![],
1205                    vec![],
1206                    vec![],
1207                    vec![],
1208                    None,
1209                    vec![],
1210                    vec![],
1211                    vec![],
1212                    vec![],
1213                    vec![],
1214                    vec![],
1215                    false,
1216                    true,
1217                    true,
1218                ),
1219                BevyConformanceV1::NotExact {
1220                    mismatch_codes: vec![BevyConformanceCodeV1::LoadDidNotSucceed],
1221                    unavailable_codes: vec![],
1222                },
1223            )
1224            .unwrap();
1225            let bytes = serde_json::to_vec(&readback).unwrap();
1226            let parsed = validate_bevy_readback_v1(bytes.as_slice()).unwrap();
1227            assert!(matches!(
1228                parsed.conformance(),
1229                BevyConformanceV1::NotExact { .. }
1230            ));
1231            assert!(matches!(
1232                parsed.observation.terminal,
1233                BevyTerminalStateV1::RootFailure { .. }
1234                    | BevyTerminalStateV1::DependencyFailure { .. }
1235            ));
1236        }
1237    }
1238
1239    #[test]
1240    fn exact_rejects_false_terminal_and_snapshot_mutation_matrix() {
1241        for (terminal, primary_verified, dependencies_verified) in [
1242            (BevyTerminalStateV1::Loaded, false, true),
1243            (BevyTerminalStateV1::Loaded, true, false),
1244            (BevyTerminalStateV1::WorkLimit, true, true),
1245            (
1246                BevyTerminalStateV1::RootFailure {
1247                    error: BevyLoadErrorCodeV1::AssetReader,
1248                },
1249                true,
1250                true,
1251            ),
1252            (
1253                BevyTerminalStateV1::DependencyFailure {
1254                    error: BevyLoadErrorCodeV1::AssetReader,
1255                },
1256                true,
1257                true,
1258            ),
1259        ] {
1260            let observation = BevyObservationV1::new(
1261                terminal,
1262                vec![],
1263                vec![],
1264                vec![],
1265                vec![],
1266                None,
1267                vec![],
1268                vec![],
1269                vec![],
1270                vec![],
1271                vec![],
1272                vec![],
1273                false,
1274                primary_verified,
1275                dependencies_verified,
1276            );
1277            let result = BevyReadbackV1::new(
1278                BevyHarnessIdentityV1::new(
1279                    "0.1.0".into(),
1280                    BEVY_READBACK_V1_RUSTC.into(),
1281                    true,
1282                    true,
1283                    frozen_lock_identity(),
1284                    1,
1285                ),
1286                InputIdentity::from_bytes(b"asset"),
1287                BevyPredictionReferenceV1::new(
1288                    InputIdentity::from_bytes(b"p"),
1289                    animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1290                    InputIdentity::from_bytes(b"q"),
1291                ),
1292                observation,
1293                BevyConformanceV1::Exact,
1294            );
1295            assert!(result.is_err());
1296        }
1297    }
1298
1299    #[test]
1300    fn canonical_writer_enforces_exact_limit_and_n_plus_one() {
1301        let exact_payload = "x".repeat(BEVY_READBACK_V1_MAX_REPORT_BYTES as usize - 2);
1302        assert_eq!(
1303            bounded_jcs_with_limit(&exact_payload, BEVY_READBACK_V1_MAX_REPORT_BYTES as usize)
1304                .unwrap()
1305                .len() as u64,
1306            BEVY_READBACK_V1_MAX_REPORT_BYTES
1307        );
1308        assert!(matches!(
1309            bounded_jcs_with_limit(
1310                &format!("{exact_payload}x"),
1311                BEVY_READBACK_V1_MAX_REPORT_BYTES as usize,
1312            ),
1313            Err(BevyReadbackV1Error::CanonicalTooLarge { .. })
1314        ));
1315    }
1316
1317    #[test]
1318    fn constructor_cannot_publish_a_report_larger_than_the_strict_reader_limit() {
1319        let text = "x".repeat(MAX_TEXT_BYTES - 4);
1320        let labels = (0..BEVY_READBACK_V1_MAX_ROWS as u32)
1321            .map(|index| BevyIndexedLabelV1::new(index, format!("{index:04}{text}")))
1322            .collect::<Vec<_>>();
1323        let warnings = (0..BEVY_READBACK_V1_MAX_ROWS as u32)
1324            .map(|index| BevyWarningV1::new(format!("{index:04}{text}"), text.clone()))
1325            .collect::<Vec<_>>();
1326        assert!(matches!(
1327            BevyReadbackV1::new(
1328                BevyHarnessIdentityV1::new(
1329                    "0.1.0".into(),
1330                    BEVY_READBACK_V1_RUSTC.into(),
1331                    true,
1332                    true,
1333                    frozen_lock_identity(),
1334                    1,
1335                ),
1336                InputIdentity::from_bytes(b"asset"),
1337                BevyPredictionReferenceV1::new(
1338                    InputIdentity::from_bytes(b"p"),
1339                    animsmith_core::PREDICTION_PROVENANCE_V4_ID.into(),
1340                    InputIdentity::from_bytes(b"q"),
1341                ),
1342                BevyObservationV1::new(
1343                    BevyTerminalStateV1::Loaded,
1344                    labels.clone(),
1345                    vec![],
1346                    vec![],
1347                    vec![],
1348                    None,
1349                    labels.clone(),
1350                    labels,
1351                    vec![],
1352                    vec![],
1353                    vec![],
1354                    warnings,
1355                    false,
1356                    true,
1357                    true,
1358                ),
1359                BevyConformanceV1::Exact,
1360            ),
1361            Err(BevyReadbackV1Error::CanonicalTooLarge { .. })
1362        ));
1363    }
1364
1365    #[test]
1366    fn unavailable_facet_never_manufactures_a_mismatch_code() {
1367        for mismatch_code in [
1368            BevyConformanceCodeV1::InventoryMismatch,
1369            BevyConformanceCodeV1::SkinMismatch,
1370            BevyConformanceCodeV1::TargetMismatch,
1371        ] {
1372            let mut mismatch = Vec::new();
1373            let mut unavailable = Vec::new();
1374            compare_available_prediction_facet(
1375                None::<Vec<BevyIndexedLabelV1>>,
1376                &vec![BevyIndexedLabelV1::new(0, "observed".into())],
1377                mismatch_code,
1378                &mut mismatch,
1379                &mut unavailable,
1380            );
1381            assert_eq!(mismatch, Vec::new());
1382            assert_eq!(
1383                unavailable,
1384                vec![BevyConformanceCodeV1::RequiredPredictionUnavailable]
1385            );
1386        }
1387    }
1388}