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_392;
30/// Frozen SHA-256 of the committed excluded-tool lock graph.
31pub const BEVY_READBACK_V1_LOCK_SHA256: &str =
32    "c0715a1711f868888432eefc6096884ae4d44b374bd9758f36d7e5bcc7ca2b49";
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                0xc0, 0x71, 0x5a, 0x17, 0x11, 0xf8, 0x68, 0x88, 0x84, 0x32, 0xee, 0xfc, 0x60, 0x96,
972                0x88, 0x4a, 0xe4, 0xd4, 0x4b, 0x37, 0x4b, 0xd9, 0x75, 0x8f, 0x36, 0xd7, 0xe5, 0xbc,
973                0xc7, 0xca, 0x2b, 0x49,
974            ],
975            BEVY_READBACK_V1_LOCK_BYTES,
976        )
977    }
978
979    #[test]
980    fn strict_reader_rejects_the_previous_mixed_bevy_patch_graph() {
981        let mut readback = valid_readback(false);
982        readback.harness.lock_identity = InputIdentity::from_sha256_digest(
983            [
984                0xe3, 0x45, 0x7e, 0x21, 0x69, 0x58, 0x74, 0xf9, 0x01, 0x10, 0xe5, 0xb9, 0x3a, 0x36,
985                0xd7, 0x68, 0x8c, 0x13, 0x4a, 0x3b, 0x92, 0x59, 0xcb, 0x1d, 0xc4, 0xd2, 0xd8, 0xe5,
986                0xf7, 0x41, 0xd1, 0xed,
987            ],
988            86_390,
989        );
990        let bytes = serde_json::to_vec(&readback).unwrap();
991        assert!(matches!(
992            validate_bevy_readback_v1(bytes.as_slice()),
993            Err(BevyReadbackV1Error::Contract(
994                "invalid frozen harness tuple"
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}