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