Skip to main content

animsmith_engine/
import_advice.rs

1//! Bounded, versioned engine-import advice for one loaded source document.
2//!
3//! V1 projects only settings already materialized by the frozen engine
4//! profile. It never reconstructs authored frame coordinates, predicts
5//! importer output, or turns measurements into undeclared project policy.
6
7use crate::project_prediction_provenance_v4;
8use crate::{
9    BakeOrExtract, EngineProfile, ResolvedClipSettings, ResolvedProfile, SettingId, SettingValue,
10    project_prediction_provenance_v1,
11};
12use animsmith_core::measure::{
13    ClipMeasurements, FrameGridMeasurement, LoopEndpointMode, MeasurementAvailability,
14};
15use animsmith_core::{
16    Config, InputIdentity, LoadedSource, MetricGrids, MovementOwner, PredictionProvenanceV1,
17    RawSourceSetCoverageStateV1, SourceObservationStateV1, SourceSetCoverageStateV1, SourceTextV1,
18    SourceUnavailableReasonV1, ToolInfo, resolve_configured_roles,
19};
20use serde::de::{Error as _, SeqAccess, Visitor};
21use serde::{Deserialize, Deserializer, Serialize};
22use serde_json::value::RawValue;
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt;
25use std::io::Read;
26use std::marker::PhantomData;
27
28/// Immutable standalone engine-import-advice contract identity.
29pub const ENGINE_IMPORT_ADVICE_V1_ID: &str = "urn:animsmith:schema:engine-import-advice:1";
30/// Standalone engine-import-advice schema version.
31pub const ENGINE_IMPORT_ADVICE_SCHEMA_VERSION: u32 = 1;
32/// Stable command spelling carried by the standalone contract.
33pub const ENGINE_IMPORT_ADVICE_COMMAND: &str = "generate-import-advice";
34/// Maximum serialized bytes accepted by the strict V1 reader.
35pub const ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
36
37const MAX_CLIPS: usize = animsmith_core::RAW_SOURCE_V1_MAX_CLIPS;
38const MAX_TEXT_BYTES: usize = animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES;
39const IDENTITY_DOMAIN: &str = "urn:animsmith:engine-import-advice-preimage:1";
40const UNITY_GENERIC_FACTS: (&str, u64) = (
41    "97afc05a02f7f9a946c66945cb84669a8a67d4dae7bf642486b94f1de3a17dd4",
42    3097,
43);
44const UNITY_HUMANOID_FACTS: (&str, u64) = (
45    "43f53df9f26ca3a1248972566029609bcd6b63194cbca399789444622680a12a",
46    2847,
47);
48const UNREAL_FACTS: (&str, u64) = (
49    "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759",
50    2169,
51);
52const GODOT_FACTS: (&str, u64) = (
53    "e9c8316d1655c487b60dd35bbfc70289952c5fa12f4718f0be09c7e9a00fbe87",
54    1166,
55);
56
57/// Domain-separated canonical identity of one import-advice payload.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct EngineImportAdviceIdentityV1(InputIdentity);
61
62impl EngineImportAdviceIdentityV1 {
63    /// SHA-256 plus canonical-preimage byte count.
64    pub const fn input_identity(&self) -> &InputIdentity {
65        &self.0
66    }
67}
68
69/// Whether requested advice could be emitted without guessing.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum EngineImportAdviceStateV1 {
73    /// Every V1 suggestion applicable to this profile was projected.
74    Available,
75    /// Advice was quarantined because required authority was unavailable.
76    Refused,
77}
78
79/// Exact reason why V1 emitted no clip suggestions.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum EngineImportAdviceRefusalReasonV1 {
83    /// This profile revision exposes no V1 setting vocabulary.
84    ProfileSettingsUnmodeled,
85    /// The raw source clip/take inventory was not exhaustive.
86    RawClipInventoryIncomplete,
87    /// A raw row lacked its normalized document index.
88    ClipIdentityUnavailable,
89    /// Raw rows, normalized clips, measurements, or settings disagreed.
90    ClipIdentityMismatch,
91    /// A required normalized measurement row was missing or malformed.
92    MeasurementUnavailable,
93}
94
95/// Stable raw-source reason retained for a source-name observation.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum EngineImportAdviceSourceUnavailableReasonV1 {
99    /// The source declaration was malformed.
100    Malformed,
101    /// The loader discarded the value.
102    Discarded,
103    /// Normalization removed the original value.
104    NormalizedAway,
105    /// Baking removed the original value.
106    BakedAway,
107    /// The loader does not model the source domain.
108    LoaderUnsupported,
109    /// The bounded projection stopped at its deterministic limit.
110    ProjectionBudgetExceeded,
111    /// The parser did not expose the value.
112    ParserUnavailable,
113}
114
115/// Bounded source animation/take name evidence.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
118pub enum EngineImportAdviceSourceNameV1 {
119    /// The source name was observed exactly.
120    Observed {
121        /// Bounded source spelling.
122        value: String,
123    },
124    /// Complete evidence proved that no source name exists.
125    ProvenAbsent,
126    /// The source name could not be established.
127    Unavailable {
128        /// Stable source-evidence reason.
129        reason: EngineImportAdviceSourceUnavailableReasonV1,
130    },
131}
132
133/// Project-declared owner of one world-movement component.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum EngineImportAdviceMovementOwnerV1 {
137    /// Gameplay/controller motion owns the component.
138    Gameplay,
139    /// Extracted animation root motion owns the component.
140    Animation,
141}
142
143/// Exact engine-neutral evidence retained beside one suggestion row.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct EngineImportAdviceClipEvidenceV1 {
147    duration_s: f64,
148    #[serde(
149        default,
150        skip_serializing_if = "Option::is_none",
151        deserialize_with = "deserialize_present_non_null"
152    )]
153    speed_mps: Option<f64>,
154    speed_mps_availability: MeasurementAvailability,
155    #[serde(
156        default,
157        skip_serializing_if = "Option::is_none",
158        deserialize_with = "deserialize_present_non_null"
159    )]
160    loop_endpoint_mode: Option<LoopEndpointMode>,
161    loop_endpoint_mode_availability: MeasurementAvailability,
162    #[serde(
163        default,
164        skip_serializing_if = "Option::is_none",
165        deserialize_with = "deserialize_present_non_null"
166    )]
167    frame_grid: Option<FrameGridMeasurement>,
168    frame_grid_availability: MeasurementAvailability,
169    #[serde(
170        rename = "loop",
171        default,
172        skip_serializing_if = "Option::is_none",
173        deserialize_with = "deserialize_present_non_null"
174    )]
175    looping: Option<bool>,
176    #[serde(
177        default,
178        skip_serializing_if = "Option::is_none",
179        deserialize_with = "deserialize_present_non_null"
180    )]
181    movement_owner_xz: Option<EngineImportAdviceMovementOwnerV1>,
182    #[serde(
183        default,
184        skip_serializing_if = "Option::is_none",
185        deserialize_with = "deserialize_present_non_null"
186    )]
187    movement_owner_y: Option<EngineImportAdviceMovementOwnerV1>,
188    #[serde(
189        default,
190        skip_serializing_if = "Option::is_none",
191        deserialize_with = "deserialize_present_non_null"
192    )]
193    movement_owner_yaw: Option<EngineImportAdviceMovementOwnerV1>,
194}
195
196impl EngineImportAdviceClipEvidenceV1 {
197    /// Measured normalized clip duration in seconds.
198    pub const fn duration_s(&self) -> f64 {
199        self.duration_s
200    }
201    /// Measured horizontal speed, when available.
202    pub const fn speed_mps(&self) -> Option<f64> {
203        self.speed_mps
204    }
205    /// Availability of [`Self::speed_mps`].
206    pub const fn speed_mps_availability(&self) -> MeasurementAvailability {
207        self.speed_mps_availability
208    }
209    /// Measured endpoint convention for an explicitly declared loop.
210    pub const fn loop_endpoint_mode(&self) -> Option<LoopEndpointMode> {
211        self.loop_endpoint_mode
212    }
213    /// Availability of [`Self::loop_endpoint_mode`].
214    pub const fn loop_endpoint_mode_availability(&self) -> MeasurementAvailability {
215        self.loop_endpoint_mode_availability
216    }
217    /// Validated declared-FPS grid, when measured.
218    pub const fn frame_grid(&self) -> Option<FrameGridMeasurement> {
219        self.frame_grid
220    }
221    /// Availability of [`Self::frame_grid`].
222    pub const fn frame_grid_availability(&self) -> MeasurementAvailability {
223        self.frame_grid_availability
224    }
225    /// Explicit project loop intent.
226    pub const fn looping(&self) -> Option<bool> {
227        self.looping
228    }
229    /// Explicit horizontal movement owner.
230    pub const fn movement_owner_xz(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
231        self.movement_owner_xz
232    }
233    /// Explicit vertical movement owner.
234    pub const fn movement_owner_y(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
235        self.movement_owner_y
236    }
237    /// Explicit yaw movement owner.
238    pub const fn movement_owner_yaw(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
239        self.movement_owner_yaw
240    }
241
242    fn validate(&self) -> Result<(), EngineImportAdviceError> {
243        if !self.duration_s.is_finite()
244            || self.duration_s < 0.0
245            || self
246                .speed_mps
247                .is_some_and(|value| !value.is_finite() || value < 0.0)
248        {
249            return Err(EngineImportAdviceError::InvalidMeasurement);
250        }
251        validate_optional(self.speed_mps.is_some(), self.speed_mps_availability)?;
252        validate_optional(
253            self.loop_endpoint_mode.is_some(),
254            self.loop_endpoint_mode_availability,
255        )?;
256        let loop_endpoint_applicability_matches = match self.looping {
257            Some(true) => {
258                self.loop_endpoint_mode_availability != MeasurementAvailability::NotApplicable
259            }
260            Some(false) | None => {
261                self.loop_endpoint_mode_availability == MeasurementAvailability::NotApplicable
262            }
263        };
264        if !loop_endpoint_applicability_matches {
265            return Err(EngineImportAdviceError::InvalidMeasurement);
266        }
267        validate_optional(self.frame_grid.is_some(), self.frame_grid_availability)?;
268        if self.frame_grid.is_some_and(|grid| {
269            !grid.fps.is_finite() || grid.fps <= 0.0 || grid.frame_intervals == 0
270        }) {
271            return Err(EngineImportAdviceError::InvalidMeasurement);
272        }
273        Ok(())
274    }
275}
276
277/// One file-scoped normalized clip and its source/intent/measurement evidence.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct EngineImportAdviceClipV1 {
281    source_clip_index: u64,
282    normalized_clip_index: u64,
283    normalized_clip_name: String,
284    source_name: EngineImportAdviceSourceNameV1,
285    evidence: EngineImportAdviceClipEvidenceV1,
286}
287
288impl EngineImportAdviceClipV1 {
289    /// Stable source animation/take index.
290    pub const fn source_clip_index(&self) -> u64 {
291        self.source_clip_index
292    }
293    /// Exact normalized document clip index.
294    pub const fn normalized_clip_index(&self) -> u64 {
295        self.normalized_clip_index
296    }
297    /// Normalized document clip name.
298    pub fn normalized_clip_name(&self) -> &str {
299        &self.normalized_clip_name
300    }
301    /// Source name observation, distinct from the normalized name.
302    pub const fn source_name(&self) -> &EngineImportAdviceSourceNameV1 {
303        &self.source_name
304    }
305    /// Project-intent and measurement evidence.
306    pub const fn evidence(&self) -> &EngineImportAdviceClipEvidenceV1 {
307        &self.evidence
308    }
309
310    fn validate(&self, ordinal: usize) -> Result<(), EngineImportAdviceError> {
311        if self.source_clip_index != ordinal as u64
312            || self.normalized_clip_index >= MAX_CLIPS as u64
313            || self.normalized_clip_name.is_empty()
314            || self.normalized_clip_name.len() > MAX_TEXT_BYTES
315        {
316            return Err(EngineImportAdviceError::InvalidClipIdentity);
317        }
318        if let EngineImportAdviceSourceNameV1::Observed { value } = &self.source_name
319            && (value.is_empty() || value.len() > MAX_TEXT_BYTES)
320        {
321            return Err(EngineImportAdviceError::InvalidClipIdentity);
322        }
323        self.evidence.validate()
324    }
325}
326
327/// Exact Unity document-level importer suggestions.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct UnityDocumentAdviceV1 {
331    convert_units: bool,
332    bake_axis_conversion: bool,
333    #[serde(
334        default,
335        skip_serializing_if = "Option::is_none",
336        deserialize_with = "deserialize_present_non_null"
337    )]
338    root_motion_source: Option<String>,
339}
340
341impl UnityDocumentAdviceV1 {
342    /// Unity Model Importer Convert Units value.
343    pub const fn convert_units(&self) -> bool {
344        self.convert_units
345    }
346    /// Unity Model Importer Bake Axis Conversion value.
347    pub const fn bake_axis_conversion(&self) -> bool {
348        self.bake_axis_conversion
349    }
350    /// Unity Generic exact motion-node path, absent for Humanoid.
351    pub fn root_motion_source(&self) -> Option<&str> {
352        self.root_motion_source.as_deref()
353    }
354}
355
356/// Exact Unity per-clip importer suggestions.
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(deny_unknown_fields)]
359pub struct UnityClipAdviceV1 {
360    normalized_clip_index: u64,
361    lock_root_rotation: bool,
362    lock_root_height_y: bool,
363    lock_root_position_xz: bool,
364}
365
366impl UnityClipAdviceV1 {
367    /// Normalized clip index linked to the root evidence row.
368    pub const fn normalized_clip_index(&self) -> u64 {
369        self.normalized_clip_index
370    }
371    /// Unity `ModelImporterClipAnimation.lockRootRotation`.
372    pub const fn lock_root_rotation(&self) -> bool {
373        self.lock_root_rotation
374    }
375    /// Unity `ModelImporterClipAnimation.lockRootHeightY`.
376    pub const fn lock_root_height_y(&self) -> bool {
377        self.lock_root_height_y
378    }
379    /// Unity `ModelImporterClipAnimation.lockRootPositionXZ`.
380    pub const fn lock_root_position_xz(&self) -> bool {
381        self.lock_root_position_xz
382    }
383}
384
385/// Profile-specific V1 advice payload.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(tag = "engine", rename_all = "kebab-case", deny_unknown_fields)]
388pub enum EngineImportAdvicePayloadV1 {
389    /// Unity Generic 6000.3 materialized settings.
390    UnityGeneric {
391        /// Document-level importer suggestions.
392        document: UnityDocumentAdviceV1,
393        /// Per-clip suggestions in normalized-index order.
394        #[serde(deserialize_with = "deserialize_unity_clips")]
395        clips: Vec<UnityClipAdviceV1>,
396    },
397    /// Unity Humanoid 6000.3 materialized settings.
398    UnityHumanoid {
399        /// Document-level importer suggestions.
400        document: UnityDocumentAdviceV1,
401        /// Per-clip suggestions in normalized-index order.
402        #[serde(deserialize_with = "deserialize_unity_clips")]
403        clips: Vec<UnityClipAdviceV1>,
404    },
405    /// Unreal 5.8 revision 1 has no modeled advice settings.
406    Unreal,
407    /// Godot 4.7 revision 1 has no modeled advice settings.
408    Godot,
409}
410
411impl EngineImportAdvicePayloadV1 {
412    /// Unity document suggestions, absent for unmodeled profiles.
413    pub const fn unity_document(&self) -> Option<&UnityDocumentAdviceV1> {
414        match self {
415            Self::UnityGeneric { document, .. } | Self::UnityHumanoid { document, .. } => {
416                Some(document)
417            }
418            Self::Unreal | Self::Godot => None,
419        }
420    }
421    /// Unity clip suggestions, empty for unmodeled profiles.
422    pub fn unity_clips(&self) -> &[UnityClipAdviceV1] {
423        match self {
424            Self::UnityGeneric { clips, .. } | Self::UnityHumanoid { clips, .. } => clips,
425            Self::Unreal | Self::Godot => &[],
426        }
427    }
428}
429
430/// Standalone producer envelope for `generate import-advice`.
431#[derive(Debug, Clone, Serialize)]
432pub struct EngineImportAdviceV1 {
433    schema_version: u32,
434    schema: &'static str,
435    tool: ToolInfo,
436    command: &'static str,
437    identity: EngineImportAdviceIdentityV1,
438    prediction_provenance: PredictionProvenanceV1,
439    state: EngineImportAdviceStateV1,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
442    clips: Vec<EngineImportAdviceClipV1>,
443    payload: EngineImportAdvicePayloadV1,
444}
445
446/// Import-advice producer or strict-reader contract failure.
447#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
448#[non_exhaustive]
449pub enum EngineImportAdviceError {
450    /// The selected profile is outside Unity/Unreal/Godot V1 advice.
451    #[error("generate import-advice requires an exact Unity, Unreal, or Godot V1 profile")]
452    UnsupportedProfile,
453    /// Engine-neutral project intent was not a valid core configuration.
454    #[error("invalid engine-import-advice configuration: {0}")]
455    InvalidConfig(String),
456    /// The standalone header was not the immutable V1 shape.
457    #[error("invalid engine-import-advice V1 header")]
458    WrongHeader,
459    /// Producer metadata violated the shared tool shape.
460    #[error("invalid engine-import-advice producer identity")]
461    InvalidTool,
462    /// Shared prediction provenance was invalid.
463    #[error("invalid prediction provenance: {0}")]
464    InvalidProvenance(String),
465    /// Exact profile tuple/facts did not match the payload variant.
466    #[error("engine-import-advice profile does not match its payload")]
467    ProfileMismatch,
468    /// Advice state, reason, or row presence contradicted the lifecycle.
469    #[error("invalid engine-import-advice lifecycle")]
470    InvalidLifecycle,
471    /// A source/normalized clip identity was malformed or duplicated.
472    #[error("invalid engine-import-advice clip identity")]
473    InvalidClipIdentity,
474    /// A retained measurement value/status pair was malformed.
475    #[error("invalid engine-import-advice measurement")]
476    InvalidMeasurement,
477    /// Materialized Unity settings did not match the advice projection.
478    #[error("engine-import-advice Unity settings do not match prediction provenance")]
479    UnitySettingsMismatch,
480    /// Too many clip rows were present.
481    #[error("engine-import-advice has {found} clips; limit is {limit}")]
482    TooManyClips {
483        /// First rejected row count.
484        found: usize,
485        /// Immutable V1 limit.
486        limit: usize,
487    },
488    /// Canonical advice identity did not match the payload.
489    #[error("engine-import-advice identity mismatch")]
490    IdentityMismatch,
491    /// The exact V2 profile tuple is not an import-advice profile.
492    #[error("generate import-advice requires an exact Godot or Unreal V2 profile")]
493    UnsupportedProfileV2,
494    /// The V2 standalone header was not the immutable shape.
495    #[error("invalid engine-import-advice V2 header")]
496    WrongV2Header,
497    /// V2 provenance failed strict semantic validation.
498    #[error("invalid V2 prediction provenance: {0}")]
499    InvalidV2Provenance(String),
500    /// V2 prediction failed strict semantic validation.
501    #[error("invalid V2 prediction: {0}")]
502    InvalidV2Prediction(String),
503    /// V2 lifecycle or facet shape was contradictory.
504    #[error("invalid engine-import-advice V2 lifecycle")]
505    InvalidV2Lifecycle,
506    /// V2 machine projection did not match its bound settings.
507    #[error("invalid engine-import-advice V2 projection")]
508    InvalidV2Projection,
509    /// V2 document settings did not contain the required closed values.
510    #[error("invalid engine-import-advice V2 settings")]
511    InvalidV2Settings,
512}
513
514/// Bounded-reader transport/shape failure.
515#[derive(Debug, thiserror::Error)]
516#[non_exhaustive]
517pub enum EngineImportAdviceReadError {
518    /// Reading serialized bytes failed.
519    #[error("cannot read engine-import-advice document: {source}")]
520    Io {
521        /// Underlying read failure.
522        #[source]
523        source: std::io::Error,
524    },
525    /// Serialized input exceeded the fixed pre-decode bound.
526    #[error("engine-import-advice document exceeds {limit} bytes")]
527    ReportTooLarge {
528        /// Immutable V1 byte limit.
529        limit: u64,
530    },
531    /// JSON shape or field vocabulary was invalid.
532    #[error("invalid engine-import-advice JSON: {source}")]
533    InvalidJson {
534        /// Strict JSON/serde failure.
535        #[source]
536        source: serde_json::Error,
537    },
538}
539
540impl EngineImportAdviceV1 {
541    /// Whether an immutable profile record has an exact V1 advice mapping.
542    ///
543    /// This static check requires no asset I/O. It is intentionally false for
544    /// Bevy and for any future profile revision until that exact record gains
545    /// a separately reviewed advice mapping.
546    pub fn supports_profile(profile: &EngineProfile) -> bool {
547        let selection = profile.selection();
548        let facts = profile.facts_identity();
549        [
550            (
551                "unity-generic",
552                1,
553                "6000.3",
554                "fbx-model-importer",
555                UNITY_GENERIC_FACTS,
556            ),
557            (
558                "unity-humanoid",
559                1,
560                "6000.3",
561                "fbx-model-importer",
562                UNITY_HUMANOID_FACTS,
563            ),
564            ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
565            ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS),
566        ]
567        .into_iter()
568        .any(|(family, revision, version, importer, identity)| {
569            selection.family() == family
570                && selection.profile_revision() == revision
571                && selection.engine_version() == version
572                && selection.importer() == importer
573                && facts.sha256() == identity.0
574                && facts.bytes() == identity.1
575        })
576    }
577
578    /// Construct one bounded result from same-load source, profile, intent,
579    /// and measurement evidence. Measurements and configured rig roles are
580    /// derived inside this boundary from `source`, so a caller cannot attach
581    /// a map computed from another normalized document.
582    ///
583    /// Exact Unreal and Godot V1 profiles produce a typed refusal because
584    /// those immutable revisions expose no setting vocabulary.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`EngineImportAdviceError`] for an unsupported profile,
589    /// invalid same-load provenance, malformed materialized Unity settings,
590    /// or a contract invariant.
591    pub fn from_source(
592        tool: ToolInfo,
593        source: &LoadedSource,
594        profile: &ResolvedProfile,
595        config: &Config,
596    ) -> Result<Self, EngineImportAdviceError> {
597        config
598            .validate()
599            .map_err(|error| EngineImportAdviceError::InvalidConfig(error.to_string()))?;
600        if !Self::supports_profile(profile.profile()) {
601            return Err(EngineImportAdviceError::UnsupportedProfile);
602        }
603        let prediction_provenance = project_prediction_provenance_v1(profile, source)
604            .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
605        let family = profile.profile().selection().family();
606        let (payload, state, refusal_reason, clips) = match family {
607            "unreal" => (
608                EngineImportAdvicePayloadV1::Unreal,
609                EngineImportAdviceStateV1::Refused,
610                Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
611                Vec::new(),
612            ),
613            "godot" => (
614                EngineImportAdvicePayloadV1::Godot,
615                EngineImportAdviceStateV1::Refused,
616                Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
617                Vec::new(),
618            ),
619            "unity-generic" | "unity-humanoid" => {
620                let generic = family == "unity-generic";
621                let document = unity_document(profile, generic)?;
622                let roles = resolve_configured_roles(&source.document().skeleton, &config.rig);
623                let grids = MetricGrids::new(source.document());
624                let measurements =
625                    animsmith_core::measure::measure_document(&grids, &roles, config);
626                match build_clip_evidence(source, &measurements, config) {
627                    Ok(clips) => match unity_clip_advice(profile, source.document(), &clips) {
628                        Ok(unity_clips) => {
629                            let payload = if generic {
630                                EngineImportAdvicePayloadV1::UnityGeneric {
631                                    document,
632                                    clips: unity_clips,
633                                }
634                            } else {
635                                EngineImportAdvicePayloadV1::UnityHumanoid {
636                                    document,
637                                    clips: unity_clips,
638                                }
639                            };
640                            (payload, EngineImportAdviceStateV1::Available, None, clips)
641                        }
642                        Err(EngineImportAdviceError::UnitySettingsMismatch) => {
643                            let payload = if generic {
644                                EngineImportAdvicePayloadV1::UnityGeneric {
645                                    document,
646                                    clips: Vec::new(),
647                                }
648                            } else {
649                                EngineImportAdvicePayloadV1::UnityHumanoid {
650                                    document,
651                                    clips: Vec::new(),
652                                }
653                            };
654                            (
655                                payload,
656                                EngineImportAdviceStateV1::Refused,
657                                Some(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch),
658                                Vec::new(),
659                            )
660                        }
661                        Err(error) => return Err(error),
662                    },
663                    Err(reason) => {
664                        let payload = if generic {
665                            EngineImportAdvicePayloadV1::UnityGeneric {
666                                document,
667                                clips: Vec::new(),
668                            }
669                        } else {
670                            EngineImportAdvicePayloadV1::UnityHumanoid {
671                                document,
672                                clips: Vec::new(),
673                            }
674                        };
675                        (
676                            payload,
677                            EngineImportAdviceStateV1::Refused,
678                            Some(reason),
679                            Vec::new(),
680                        )
681                    }
682                }
683            }
684            _ => return Err(EngineImportAdviceError::UnsupportedProfile),
685        };
686        let mut report = Self {
687            schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
688            schema: ENGINE_IMPORT_ADVICE_V1_ID,
689            tool,
690            command: ENGINE_IMPORT_ADVICE_COMMAND,
691            identity: EngineImportAdviceIdentityV1(InputIdentity::from_bytes(&[])),
692            prediction_provenance,
693            state,
694            refusal_reason,
695            clips,
696            payload,
697        };
698        report.validate_without_identity()?;
699        report.identity = EngineImportAdviceIdentityV1(report.computed_identity());
700        Ok(report)
701    }
702
703    /// Canonical advice identity.
704    pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
705        &self.identity
706    }
707    /// Exact shared profile/settings/source/closure provenance.
708    pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
709        &self.prediction_provenance
710    }
711    /// Advice availability state.
712    pub const fn state(&self) -> EngineImportAdviceStateV1 {
713        self.state
714    }
715    /// Refusal reason, present exactly when state is refused.
716    pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
717        self.refusal_reason
718    }
719    /// File-scoped clip evidence, empty for a refused result.
720    pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
721        &self.clips
722    }
723    /// Profile-specific setting projection or unmodeled payload.
724    pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
725        &self.payload
726    }
727
728    fn validate_without_identity(&self) -> Result<(), EngineImportAdviceError> {
729        if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
730            || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
731            || self.command != ENGINE_IMPORT_ADVICE_COMMAND
732        {
733            return Err(EngineImportAdviceError::WrongHeader);
734        }
735        self.prediction_provenance
736            .validate()
737            .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
738        validate_profile(&self.prediction_provenance, &self.payload)?;
739        if self.clips.len() > MAX_CLIPS {
740            return Err(EngineImportAdviceError::TooManyClips {
741                found: self.clips.len(),
742                limit: MAX_CLIPS,
743            });
744        }
745        let mut normalized = BTreeSet::new();
746        for (ordinal, clip) in self.clips.iter().enumerate() {
747            clip.validate(ordinal)?;
748            if !normalized.insert(clip.normalized_clip_index) {
749                return Err(EngineImportAdviceError::InvalidClipIdentity);
750            }
751        }
752        if normalized
753            .iter()
754            .copied()
755            .ne((0..self.clips.len()).map(|index| index as u64))
756        {
757            return Err(EngineImportAdviceError::InvalidClipIdentity);
758        }
759        let raw_clips_complete = self
760            .prediction_provenance
761            .raw_source()
762            .clips_coverage()
763            .state()
764            == RawSourceSetCoverageStateV1::Complete;
765        match (self.state, self.refusal_reason) {
766            (EngineImportAdviceStateV1::Available, None) => {
767                if !raw_clips_complete
768                    || matches!(
769                        self.payload,
770                        EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
771                    )
772                    || self.payload.unity_clips().len() != self.clips.len()
773                {
774                    return Err(EngineImportAdviceError::InvalidLifecycle);
775                }
776                for (normalized_index, advice) in self.payload.unity_clips().iter().enumerate() {
777                    if advice.normalized_clip_index != normalized_index as u64 {
778                        return Err(EngineImportAdviceError::InvalidClipIdentity);
779                    }
780                }
781                validate_unity_settings_against_provenance(
782                    &self.prediction_provenance,
783                    &self.clips,
784                    &self.payload,
785                )?;
786            }
787            (EngineImportAdviceStateV1::Refused, Some(reason)) => {
788                if !self.clips.is_empty() || !self.payload.unity_clips().is_empty() {
789                    return Err(EngineImportAdviceError::InvalidLifecycle);
790                }
791                let unmodeled = matches!(
792                    self.payload,
793                    EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
794                );
795                if (reason == EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled)
796                    != unmodeled
797                {
798                    return Err(EngineImportAdviceError::InvalidLifecycle);
799                }
800                if !unmodeled {
801                    let coverage_matches_reason = match reason {
802                        EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
803                            !raw_clips_complete
804                        }
805                        EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable
806                        | EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch
807                        | EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => {
808                            raw_clips_complete
809                        }
810                        EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => false,
811                    };
812                    if !coverage_matches_reason {
813                        return Err(EngineImportAdviceError::InvalidLifecycle);
814                    }
815                    validate_unity_document_against_provenance(
816                        &self.prediction_provenance,
817                        &self.payload,
818                    )?;
819                }
820            }
821            _ => return Err(EngineImportAdviceError::InvalidLifecycle),
822        }
823        Ok(())
824    }
825
826    fn computed_identity(&self) -> InputIdentity {
827        let mut encoder = AdviceEncoder::new(IDENTITY_DOMAIN);
828        encode_identity(
829            &mut encoder,
830            self.prediction_provenance.identity().input_identity(),
831        );
832        encoder.token(match self.state {
833            EngineImportAdviceStateV1::Available => "available",
834            EngineImportAdviceStateV1::Refused => "refused",
835        });
836        encoder.bool(self.refusal_reason.is_some());
837        if let Some(reason) = self.refusal_reason {
838            encoder.token(refusal_reason_name(reason));
839        }
840        encoder.count(self.clips.len());
841        for clip in &self.clips {
842            encode_clip(&mut encoder, clip);
843        }
844        encode_payload(&mut encoder, &self.payload);
845        InputIdentity::from_bytes(&encoder.bytes)
846    }
847}
848
849fn build_clip_evidence(
850    source: &LoadedSource,
851    measurements: &BTreeMap<String, ClipMeasurements>,
852    config: &Config,
853) -> Result<Vec<EngineImportAdviceClipV1>, EngineImportAdviceRefusalReasonV1> {
854    let raw = source.source_facts().clips();
855    if raw.coverage().state() != SourceSetCoverageStateV1::Complete {
856        return Err(EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete);
857    }
858    let document = source.document();
859    if raw.rows().len() != document.clips.len() || measurements.len() != document.clips.len() {
860        return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
861    }
862    let mut normalized = BTreeSet::new();
863    let mut rows = Vec::with_capacity(raw.rows().len());
864    for (ordinal, raw_clip) in raw.rows().iter().enumerate() {
865        if raw_clip.source_clip_index() != ordinal {
866            return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
867        }
868        let normalized_index = match raw_clip.normalized_clip_index().state() {
869            SourceObservationStateV1::Observed(value) => *value,
870            SourceObservationStateV1::ProvenAbsent | SourceObservationStateV1::Unavailable(_) => {
871                return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable);
872            }
873        };
874        if normalized_index >= document.clips.len() || !normalized.insert(normalized_index) {
875            return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
876        }
877        let clip = &document.clips[normalized_index];
878        let Some(measurement) = measurements.get(&clip.name) else {
879            return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
880        };
881        let expectations = config.expectations_for(&clip.name);
882        let evidence = EngineImportAdviceClipEvidenceV1 {
883            duration_s: measurement.duration_s,
884            speed_mps: measurement.speed_mps,
885            speed_mps_availability: measurement.speed_mps_availability,
886            loop_endpoint_mode: measurement.loop_endpoint_mode,
887            loop_endpoint_mode_availability: measurement.loop_endpoint_mode_availability,
888            frame_grid: measurement.frame_grid,
889            frame_grid_availability: measurement.frame_grid_availability,
890            looping: expectations.looping,
891            movement_owner_xz: expectations.movement_owner_xz.map(movement_owner),
892            movement_owner_y: expectations.movement_owner_y.map(movement_owner),
893            movement_owner_yaw: expectations.movement_owner_yaw.map(movement_owner),
894        };
895        if evidence.validate().is_err() {
896            return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
897        }
898        rows.push(EngineImportAdviceClipV1 {
899            source_clip_index: ordinal as u64,
900            normalized_clip_index: normalized_index as u64,
901            normalized_clip_name: clip.name.clone(),
902            source_name: source_name(raw_clip.source_name().state()),
903            evidence,
904        });
905    }
906    if normalized.len() != document.clips.len() {
907        return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
908    }
909    Ok(rows)
910}
911
912fn unity_document(
913    profile: &ResolvedProfile,
914    generic: bool,
915) -> Result<UnityDocumentAdviceV1, EngineImportAdviceError> {
916    let convert_units = bool_setting(profile.document_settings().get(&SettingId::ConvertUnits))?;
917    let bake_axis_conversion = bool_setting(
918        profile
919            .document_settings()
920            .get(&SettingId::BakeAxisConversion),
921    )?;
922    let root_motion_source = if generic {
923        match profile
924            .document_settings()
925            .get(&SettingId::RootMotionSource)
926        {
927            Some(SettingValue::SourceTransformPath(value)) => Some(value.clone()),
928            _ => return Err(EngineImportAdviceError::UnitySettingsMismatch),
929        }
930    } else {
931        None
932    };
933    Ok(UnityDocumentAdviceV1 {
934        convert_units,
935        bake_axis_conversion,
936        root_motion_source,
937    })
938}
939
940fn unity_clip_advice(
941    profile: &ResolvedProfile,
942    document: &animsmith_core::Document,
943    clips: &[EngineImportAdviceClipV1],
944) -> Result<Vec<UnityClipAdviceV1>, EngineImportAdviceError> {
945    let settings = settings_by_document_index(profile.clip_settings(), document)?;
946    let mut advice = clips
947        .iter()
948        .map(|clip| {
949            let row = settings
950                .get(clip.normalized_clip_index as usize)
951                .ok_or(EngineImportAdviceError::UnitySettingsMismatch)?;
952            Ok(UnityClipAdviceV1 {
953                normalized_clip_index: clip.normalized_clip_index,
954                lock_root_rotation: bake_setting(row.settings().get(&SettingId::RootRotation))?,
955                lock_root_height_y: bake_setting(row.settings().get(&SettingId::RootPositionY))?,
956                lock_root_position_xz: bake_setting(
957                    row.settings().get(&SettingId::RootPositionXz),
958                )?,
959            })
960        })
961        .collect::<Result<Vec<_>, _>>()?;
962    advice.sort_by_key(|clip| clip.normalized_clip_index);
963    Ok(advice)
964}
965
966fn settings_by_document_index<'a>(
967    settings: &'a [ResolvedClipSettings],
968    document: &animsmith_core::Document,
969) -> Result<Vec<&'a ResolvedClipSettings>, EngineImportAdviceError> {
970    if settings.len() != document.clips.len() {
971        return Err(EngineImportAdviceError::UnitySettingsMismatch);
972    }
973    let mut used = vec![false; settings.len()];
974    document
975        .clips
976        .iter()
977        .map(|clip| {
978            let Some((index, row)) = settings
979                .iter()
980                .enumerate()
981                .find(|(index, row)| !used[*index] && row.clip_name() == clip.name)
982            else {
983                return Err(EngineImportAdviceError::UnitySettingsMismatch);
984            };
985            used[index] = true;
986            Ok(row)
987        })
988        .collect()
989}
990
991fn bool_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
992    match value {
993        Some(SettingValue::Boolean(value)) => Ok(*value),
994        _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
995    }
996}
997
998fn bake_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
999    match value {
1000        Some(SettingValue::BakeOrExtract(BakeOrExtract::Bake)) => Ok(true),
1001        Some(SettingValue::BakeOrExtract(BakeOrExtract::Extract)) => Ok(false),
1002        _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
1003    }
1004}
1005
1006fn movement_owner(value: MovementOwner) -> EngineImportAdviceMovementOwnerV1 {
1007    match value {
1008        MovementOwner::Gameplay => EngineImportAdviceMovementOwnerV1::Gameplay,
1009        MovementOwner::Animation => EngineImportAdviceMovementOwnerV1::Animation,
1010    }
1011}
1012
1013fn source_name(state: &SourceObservationStateV1<SourceTextV1>) -> EngineImportAdviceSourceNameV1 {
1014    match state {
1015        SourceObservationStateV1::Observed(value) => EngineImportAdviceSourceNameV1::Observed {
1016            value: value.as_str().to_owned(),
1017        },
1018        SourceObservationStateV1::ProvenAbsent => EngineImportAdviceSourceNameV1::ProvenAbsent,
1019        SourceObservationStateV1::Unavailable(reason) => {
1020            EngineImportAdviceSourceNameV1::Unavailable {
1021                reason: source_reason(*reason),
1022            }
1023        }
1024    }
1025}
1026
1027fn source_reason(reason: SourceUnavailableReasonV1) -> EngineImportAdviceSourceUnavailableReasonV1 {
1028    match reason {
1029        SourceUnavailableReasonV1::Malformed => {
1030            EngineImportAdviceSourceUnavailableReasonV1::Malformed
1031        }
1032        SourceUnavailableReasonV1::Discarded => {
1033            EngineImportAdviceSourceUnavailableReasonV1::Discarded
1034        }
1035        SourceUnavailableReasonV1::NormalizedAway => {
1036            EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway
1037        }
1038        SourceUnavailableReasonV1::BakedAway => {
1039            EngineImportAdviceSourceUnavailableReasonV1::BakedAway
1040        }
1041        SourceUnavailableReasonV1::LoaderUnsupported => {
1042            EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported
1043        }
1044        SourceUnavailableReasonV1::ProjectionBudgetExceeded => {
1045            EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded
1046        }
1047        SourceUnavailableReasonV1::ParserUnavailable => {
1048            EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable
1049        }
1050    }
1051}
1052
1053fn validate_optional(
1054    present: bool,
1055    availability: MeasurementAvailability,
1056) -> Result<(), EngineImportAdviceError> {
1057    if matches!(availability, MeasurementAvailability::Measured) != present {
1058        return Err(EngineImportAdviceError::InvalidMeasurement);
1059    }
1060    Ok(())
1061}
1062
1063// -------------------------------------------------------------------------
1064// Revision-2 document import-setting advice
1065// -------------------------------------------------------------------------
1066
1067/// Immutable standalone engine-import-advice V2 contract identity.
1068pub const ENGINE_IMPORT_ADVICE_V2_ID: &str = "urn:animsmith:schema:engine-import-advice:2";
1069/// Standalone engine-import-advice V2 schema version.
1070pub const ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION: u32 = 2;
1071/// Maximum serialized bytes accepted by the strict V2 reader.
1072pub const ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES: u64 = ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES;
1073
1074const ENGINE_IMPORT_ADVICE_V2_IDENTITY_DOMAIN: &str =
1075    "urn:animsmith:engine-import-advice-preimage:2";
1076const IMPORT_SETTING_PROJECTION_SCOPE: &str = "import-setting-projection";
1077const GODOT_V2_FACTS: (&str, u64) = (
1078    "5911192e94b514bc1a82a3914def9444a45b7f4999e8976585e28af6288833d1",
1079    1571,
1080);
1081const UNREAL_V2_FACTS: (&str, u64) = (
1082    "77df5554413b29767fb8d7f4f8d65d199ea54da80ae08e8c006128eb6cf9e329",
1083    1393,
1084);
1085
1086/// Whether the exact V2 document projection was emitted.
1087#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1088#[serde(rename_all = "snake_case")]
1089pub enum EngineImportAdviceStateV2 {
1090    /// Every required document setting was projected from immutable authority.
1091    Available,
1092    /// Required authority was unavailable, so no machine projection was emitted.
1093    Refused,
1094}
1095
1096/// Why a V2 document projection was refused.
1097#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1098#[serde(rename_all = "snake_case")]
1099pub enum EngineImportAdviceRefusalReasonV2 {
1100    /// Same-load dependency closure was not complete.
1101    DependencyClosureIncomplete,
1102}
1103
1104/// Native scalar carried by a V2 importer projection.
1105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1106#[serde(rename_all = "snake_case")]
1107pub enum EngineImportAdviceProjectionValueV2 {
1108    /// Native Boolean field.
1109    Boolean(bool),
1110    /// Native unsigned integer field, including Unreal's source-determined 0.
1111    UnsignedInteger(u32),
1112}
1113
1114/// One exact native importer field and its resolved authority.
1115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1116#[serde(deny_unknown_fields)]
1117pub struct EngineImportAdviceProjectionFieldV2 {
1118    /// Engine-native field key.
1119    pub key: String,
1120    /// Native projected value.
1121    pub value: EngineImportAdviceProjectionValueV2,
1122    /// Authority for the corresponding resolved setting.
1123    pub value_origin: animsmith_core::EngineSettingValueOriginV3,
1124}
1125
1126/// Exact bounded document-level importer projection.
1127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1128#[serde(deny_unknown_fields)]
1129pub struct EngineImportAdviceProjectionV2 {
1130    /// Projection target.
1131    pub projection_kind: animsmith_core::ImportSettingProjectionKindV1,
1132    /// Canonically key-ordered native fields.
1133    #[serde(deserialize_with = "deserialize_v2_projection_fields")]
1134    pub fields: Vec<EngineImportAdviceProjectionFieldV2>,
1135}
1136
1137impl EngineImportAdviceProjectionV2 {
1138    fn validate(&self) -> Result<(), EngineImportAdviceError> {
1139        if self.fields.is_empty() || self.fields.len() > MAX_CLIPS {
1140            return Err(EngineImportAdviceError::InvalidV2Projection);
1141        }
1142        if self
1143            .fields
1144            .iter()
1145            .any(|field| field.key.is_empty() || field.key.len() > MAX_TEXT_BYTES)
1146            || self
1147                .fields
1148                .windows(2)
1149                .any(|pair| pair[0].key >= pair[1].key)
1150        {
1151            return Err(EngineImportAdviceError::InvalidV2Projection);
1152        }
1153        Ok(())
1154    }
1155}
1156
1157/// Domain-separated canonical identity of one V2 advice envelope.
1158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1159#[serde(transparent)]
1160pub struct EngineImportAdviceIdentityV2(InputIdentity);
1161
1162impl EngineImportAdviceIdentityV2 {
1163    /// SHA-256 plus canonical-preimage byte count.
1164    pub const fn input_identity(&self) -> &InputIdentity {
1165        &self.0
1166    }
1167}
1168
1169/// Standalone producer envelope for document-level V2 import advice.
1170#[derive(Debug, Clone, Serialize)]
1171pub struct EngineImportAdviceV2 {
1172    schema_version: u32,
1173    schema: &'static str,
1174    tool: ToolInfo,
1175    command: &'static str,
1176    identity: EngineImportAdviceIdentityV2,
1177    prediction_provenance: animsmith_core::PredictionProvenanceV4,
1178    basis: animsmith_core::EnginePredictionBasisV4,
1179    #[serde(default, skip_serializing_if = "Option::is_none")]
1180    projection: Option<EngineImportAdviceProjectionV2>,
1181    state: EngineImportAdviceStateV2,
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1184}
1185
1186impl EngineImportAdviceV2 {
1187    /// Whether an engine-local profile record has the exact frozen V2 advice
1188    /// tuple and import-setting fact token.
1189    pub fn supports_profile(profile: &crate::EngineProfileV2) -> bool {
1190        let selection = profile.selection();
1191        let expected = match (
1192            selection.family(),
1193            selection.profile_revision(),
1194            selection.engine_version(),
1195            selection.importer(),
1196        ) {
1197            ("godot", 2, "4.7", "resource-importer-scene") => {
1198                ("urn:animsmith:engine-profile:godot:2", "godot_params")
1199            }
1200            ("unreal", 2, "5.8", "fbx-importer") => (
1201                "urn:animsmith:engine-profile:unreal:2",
1202                "unreal_fbx_import_data",
1203            ),
1204            _ => return false,
1205        };
1206        let Ok(core) = crate::project_engine_profile_v2(profile) else {
1207            return false;
1208        };
1209        let facts_identity_matches = match expected.1 {
1210            "godot_params" => {
1211                core.facts_identity().sha256() == GODOT_V2_FACTS.0
1212                    && core.facts_identity().bytes() == GODOT_V2_FACTS.1
1213            }
1214            "unreal_fbx_import_data" => {
1215                core.facts_identity().sha256() == UNREAL_V2_FACTS.0
1216                    && core.facts_identity().bytes() == UNREAL_V2_FACTS.1
1217            }
1218            _ => false,
1219        };
1220        profile.profile_urn() == expected.0
1221            && facts_identity_matches
1222            && matches!(
1223                profile
1224                    .facts()
1225                    .iter()
1226                    .find(|fact| {
1227                        fact.id() == animsmith_core::EngineFactIdV2::ImportSettingProjection
1228                    })
1229                    .map(|fact| fact.state()),
1230                Some(animsmith_core::EngineFactStateV2::Known(
1231                    animsmith_core::EngineFactValueV2::Token(token)
1232                )) if token == expected.1
1233            )
1234    }
1235
1236    /// Construct V2 advice from one resolved profile and the same loaded source.
1237    ///
1238    /// The source is never reopened or reparsed. Profile/settings and all
1239    /// provenance are projected from the supplied same-load values.
1240    pub fn from_source(
1241        tool: ToolInfo,
1242        source: &LoadedSource,
1243        profile: &crate::ResolvedProfileSettingsV2,
1244    ) -> Result<Self, EngineImportAdviceError> {
1245        if !Self::supports_profile(profile.profile()) {
1246            return Err(EngineImportAdviceError::UnsupportedProfileV2);
1247        }
1248        let provenance = project_prediction_provenance_v4(profile, source, Vec::new())
1249            .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1250        let basis = v2_basis(&provenance)?;
1251        let (state, refusal_reason, projection) =
1252            if source.dependency_closure().coverage().is_complete() {
1253                let projection = v2_projection(&provenance)?;
1254                projection.validate()?;
1255                (EngineImportAdviceStateV2::Available, None, Some(projection))
1256            } else {
1257                (
1258                    EngineImportAdviceStateV2::Refused,
1259                    Some(EngineImportAdviceRefusalReasonV2::DependencyClosureIncomplete),
1260                    None,
1261                )
1262            };
1263        let mut report = Self {
1264            schema_version: ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION,
1265            schema: ENGINE_IMPORT_ADVICE_V2_ID,
1266            tool,
1267            command: ENGINE_IMPORT_ADVICE_COMMAND,
1268            identity: EngineImportAdviceIdentityV2(InputIdentity::from_bytes(&[])),
1269            prediction_provenance: provenance,
1270            basis,
1271            projection,
1272            state,
1273            refusal_reason,
1274        };
1275        report.validate_without_identity()?;
1276        report.identity = EngineImportAdviceIdentityV2(report.computed_identity());
1277        Ok(report)
1278    }
1279
1280    /// Canonical V2 advice identity.
1281    pub const fn identity(&self) -> &EngineImportAdviceIdentityV2 {
1282        &self.identity
1283    }
1284    /// Same-load V4 provenance.
1285    pub const fn prediction_provenance(&self) -> &animsmith_core::PredictionProvenanceV4 {
1286        &self.prediction_provenance
1287    }
1288    /// Canonical prediction basis.
1289    pub const fn basis(&self) -> &animsmith_core::EnginePredictionBasisV4 {
1290        &self.basis
1291    }
1292    /// Exact native importer projection, when available.
1293    pub const fn projection(&self) -> Option<&EngineImportAdviceProjectionV2> {
1294        self.projection.as_ref()
1295    }
1296    /// Advice lifecycle state.
1297    pub const fn state(&self) -> EngineImportAdviceStateV2 {
1298        self.state
1299    }
1300    /// Typed refusal reason, when refused.
1301    pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV2> {
1302        self.refusal_reason
1303    }
1304
1305    fn validate_without_identity(&self) -> Result<(), EngineImportAdviceError> {
1306        if self.schema_version != ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION
1307            || self.schema != ENGINE_IMPORT_ADVICE_V2_ID
1308            || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1309        {
1310            return Err(EngineImportAdviceError::WrongV2Header);
1311        }
1312        self.prediction_provenance
1313            .validate()
1314            .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1315        validate_v2_semantics(
1316            &self.prediction_provenance,
1317            &self.basis,
1318            self.projection.as_ref(),
1319            self.state,
1320            self.refusal_reason,
1321        )?;
1322        Ok(())
1323    }
1324
1325    fn computed_identity(&self) -> InputIdentity {
1326        let mut encoder = AdviceEncoder::new(ENGINE_IMPORT_ADVICE_V2_IDENTITY_DOMAIN);
1327        encode_identity(
1328            &mut encoder,
1329            self.prediction_provenance.identity().input_identity(),
1330        );
1331        encoder.token(
1332            &serde_json::to_string(&self.basis).expect("V2 basis serialization is infallible"),
1333        );
1334        encoder.bool(self.projection.is_some());
1335        if let Some(projection) = self.projection.as_ref() {
1336            encoder.token(
1337                &serde_json::to_string(projection)
1338                    .expect("V2 projection serialization is infallible"),
1339            );
1340        }
1341        encoder.token(match self.state {
1342            EngineImportAdviceStateV2::Available => "available",
1343            EngineImportAdviceStateV2::Refused => "refused",
1344        });
1345        encoder.bool(self.refusal_reason.is_some());
1346        if let Some(reason) = self.refusal_reason {
1347            encoder.token(v2_refusal_name(reason));
1348        }
1349        InputIdentity::from_bytes(&encoder.bytes)
1350    }
1351}
1352
1353/// Staged bounded reader input for the V2 advice contract.
1354#[derive(Debug, Deserialize)]
1355#[serde(deny_unknown_fields)]
1356pub struct EngineImportAdviceInputV2 {
1357    schema_version: u32,
1358    schema: String,
1359    tool: ToolInputV1,
1360    command: String,
1361    identity: EngineImportAdviceIdentityV2,
1362    prediction_provenance: Box<RawValue>,
1363    basis: Box<RawValue>,
1364    #[serde(default, deserialize_with = "deserialize_present_non_null")]
1365    projection: Option<EngineImportAdviceProjectionV2>,
1366    state: EngineImportAdviceStateV2,
1367    #[serde(default, deserialize_with = "deserialize_present_non_null")]
1368    refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1369}
1370
1371impl EngineImportAdviceInputV2 {
1372    /// Read at most the immutable V2 report bound before JSON decoding.
1373    pub fn read_from(reader: impl Read) -> Result<Self, EngineImportAdviceReadError> {
1374        let mut bytes = Vec::new();
1375        reader
1376            .take(ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES.saturating_add(1))
1377            .read_to_end(&mut bytes)
1378            .map_err(|source| EngineImportAdviceReadError::Io { source })?;
1379        if bytes.len() as u64 > ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES {
1380            return Err(EngineImportAdviceReadError::ReportTooLarge {
1381                limit: ENGINE_IMPORT_ADVICE_V2_MAX_REPORT_BYTES,
1382            });
1383        }
1384        serde_json::from_slice(&bytes)
1385            .map_err(|source| EngineImportAdviceReadError::InvalidJson { source })
1386    }
1387
1388    /// Validate and materialize the strict V2 readback representation.
1389    pub fn into_report(self) -> Result<EngineImportAdviceReadbackV2, EngineImportAdviceError> {
1390        if self.schema_version != ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION
1391            || self.schema != ENGINE_IMPORT_ADVICE_V2_ID
1392            || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1393        {
1394            return Err(EngineImportAdviceError::WrongV2Header);
1395        }
1396        validate_tool(&self.tool)?;
1397        let prediction_provenance: animsmith_core::PredictionProvenanceV4 =
1398            serde_json::from_str(self.prediction_provenance.get())
1399                .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1400        let basis: animsmith_core::EnginePredictionBasisV4 = serde_json::from_str(self.basis.get())
1401            .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?;
1402        let report = EngineImportAdviceReadbackV2 {
1403            tool: EngineImportAdviceToolReadbackV1 {
1404                name: self.tool.name,
1405                version: self.tool.version,
1406                revision: required_nullable_value(self.tool.source.revision),
1407                dirty: required_nullable_value(self.tool.source.dirty),
1408            },
1409            identity: self.identity,
1410            prediction_provenance,
1411            basis,
1412            projection: self.projection,
1413            state: self.state,
1414            refusal_reason: self.refusal_reason,
1415        };
1416        report.validate()?;
1417        Ok(report)
1418    }
1419}
1420
1421/// Validated strict read-side representation of one V2 advice document.
1422#[derive(Debug, Clone)]
1423pub struct EngineImportAdviceReadbackV2 {
1424    tool: EngineImportAdviceToolReadbackV1,
1425    identity: EngineImportAdviceIdentityV2,
1426    prediction_provenance: animsmith_core::PredictionProvenanceV4,
1427    basis: animsmith_core::EnginePredictionBasisV4,
1428    projection: Option<EngineImportAdviceProjectionV2>,
1429    state: EngineImportAdviceStateV2,
1430    refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1431}
1432
1433impl EngineImportAdviceReadbackV2 {
1434    /// Validated producer metadata.
1435    pub const fn tool(&self) -> &EngineImportAdviceToolReadbackV1 {
1436        &self.tool
1437    }
1438    /// Canonical advice identity.
1439    pub const fn identity(&self) -> &EngineImportAdviceIdentityV2 {
1440        &self.identity
1441    }
1442    /// Same-load V4 provenance.
1443    pub const fn prediction_provenance(&self) -> &animsmith_core::PredictionProvenanceV4 {
1444        &self.prediction_provenance
1445    }
1446    /// Canonical prediction basis.
1447    pub const fn basis(&self) -> &animsmith_core::EnginePredictionBasisV4 {
1448        &self.basis
1449    }
1450    /// Native importer projection, when available.
1451    pub const fn projection(&self) -> Option<&EngineImportAdviceProjectionV2> {
1452        self.projection.as_ref()
1453    }
1454    /// Advice lifecycle state.
1455    pub const fn state(&self) -> EngineImportAdviceStateV2 {
1456        self.state
1457    }
1458    /// Typed refusal reason.
1459    pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV2> {
1460        self.refusal_reason
1461    }
1462
1463    fn validate(&self) -> Result<(), EngineImportAdviceError> {
1464        self.prediction_provenance
1465            .validate()
1466            .map_err(|error| EngineImportAdviceError::InvalidV2Provenance(error.to_string()))?;
1467        validate_v2_semantics(
1468            &self.prediction_provenance,
1469            &self.basis,
1470            self.projection.as_ref(),
1471            self.state,
1472            self.refusal_reason,
1473        )?;
1474        let surrogate = EngineImportAdviceV2 {
1475            schema_version: ENGINE_IMPORT_ADVICE_V2_SCHEMA_VERSION,
1476            schema: ENGINE_IMPORT_ADVICE_V2_ID,
1477            tool: ToolInfo::animsmith(animsmith_core::ToolSource::new(None, None)),
1478            command: ENGINE_IMPORT_ADVICE_COMMAND,
1479            identity: self.identity.clone(),
1480            prediction_provenance: self.prediction_provenance.clone(),
1481            basis: self.basis.clone(),
1482            projection: self.projection.clone(),
1483            state: self.state,
1484            refusal_reason: self.refusal_reason,
1485        };
1486        if surrogate.computed_identity() != self.identity.0 {
1487            return Err(EngineImportAdviceError::IdentityMismatch);
1488        }
1489        Ok(())
1490    }
1491}
1492
1493fn v2_supporting_sources(
1494    provenance: &animsmith_core::PredictionProvenanceV4,
1495) -> Vec<&animsmith_core::EnginePrimarySourceV2> {
1496    let required = provenance
1497        .settings()
1498        .document_settings()
1499        .iter()
1500        .map(|row| row.id())
1501        .collect::<BTreeSet<_>>();
1502    provenance
1503        .profile()
1504        .primary_sources()
1505        .iter()
1506        .filter(|source| {
1507            source
1508                .supported_fact_ids()
1509                .contains(&animsmith_core::EngineFactIdV2::ImportSettingProjection)
1510                && required
1511                    .iter()
1512                    .all(|id| source.supported_setting_ids().contains(id))
1513        })
1514        .collect()
1515}
1516
1517fn v2_basis(
1518    provenance: &animsmith_core::PredictionProvenanceV4,
1519) -> Result<animsmith_core::EnginePredictionBasisV4, EngineImportAdviceError> {
1520    use animsmith_core::{PredictionBasisReferenceV1 as Ref, PredictionBasisReferenceV2 as Ref2};
1521    let mut references = Vec::new();
1522    references.push(animsmith_core::PredictionBasisReferenceV4::v2(Ref2::v1(
1523        Ref::profile_fact("import_setting_projection")
1524            .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?,
1525    )));
1526    for row in provenance.settings().document_settings() {
1527        references.push(animsmith_core::PredictionBasisReferenceV4::v2(Ref2::v1(
1528            Ref::resolved_setting(
1529                animsmith_core::ResolvedSettingLocationV1::Document,
1530                row.id().as_str(),
1531            )
1532            .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?,
1533        )));
1534    }
1535    for source in v2_supporting_sources(provenance) {
1536        references.push(animsmith_core::PredictionBasisReferenceV4::v2(Ref2::v1(
1537            Ref::primary_source(source.id())
1538                .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?,
1539        )));
1540    }
1541    animsmith_core::EnginePredictionBasisV4::new(references)
1542        .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))
1543}
1544
1545fn v2_projection(
1546    provenance: &animsmith_core::PredictionProvenanceV4,
1547) -> Result<EngineImportAdviceProjectionV2, EngineImportAdviceError> {
1548    use animsmith_core::{EngineSettingIdV2 as Id, EngineSettingValueV2 as Value};
1549    let family = provenance.profile().selection().family();
1550    let expected_token = match family {
1551        "godot" => Some("godot_params"),
1552        "unreal" => Some("unreal_fbx_import_data"),
1553        _ => None,
1554    };
1555    if !matches!(
1556        (expected_token, provenance.profile().fact(animsmith_core::EngineFactIdV2::ImportSettingProjection)),
1557        (Some(expected), Some(fact))
1558            if matches!(fact.state(), animsmith_core::EngineFactStateV2::Known(animsmith_core::EngineFactValueV2::Token(token)) if token == expected)
1559    ) {
1560        return Err(EngineImportAdviceError::InvalidV2Projection);
1561    }
1562    let mut fields = Vec::new();
1563    match (
1564        family,
1565        provenance.profile().selection().profile_revision(),
1566        provenance.profile().selection().engine_version(),
1567        provenance.profile().selection().importer(),
1568        provenance.source_format(),
1569    ) {
1570        (
1571            "godot",
1572            2,
1573            "4.7",
1574            "resource-importer-scene",
1575            animsmith_core::SourceFormatV1::Glb | animsmith_core::SourceFormatV1::GltfJson,
1576        ) => {
1577            let fps = provenance
1578                .settings()
1579                .document_setting(Id::AnimationFps)
1580                .ok_or(EngineImportAdviceError::InvalidV2Settings)?;
1581            let trimming = provenance
1582                .settings()
1583                .document_setting(Id::AnimationTrimming)
1584                .ok_or(EngineImportAdviceError::InvalidV2Settings)?;
1585            let Value::PositiveInteger(fps_value) = fps.value() else {
1586                return Err(EngineImportAdviceError::InvalidV2Settings);
1587            };
1588            let Value::Boolean(trimming_value) = trimming.value() else {
1589                return Err(EngineImportAdviceError::InvalidV2Settings);
1590            };
1591            fields.push(EngineImportAdviceProjectionFieldV2 {
1592                key: "animation/fps".into(),
1593                value: EngineImportAdviceProjectionValueV2::UnsignedInteger(*fps_value),
1594                value_origin: fps.value_origin(),
1595            });
1596            fields.push(EngineImportAdviceProjectionFieldV2 {
1597                key: "animation/trimming".into(),
1598                value: EngineImportAdviceProjectionValueV2::Boolean(*trimming_value),
1599                value_origin: trimming.value_origin(),
1600            });
1601            fields.sort_by(|left, right| left.key.cmp(&right.key));
1602            Ok(EngineImportAdviceProjectionV2 {
1603                projection_kind: animsmith_core::ImportSettingProjectionKindV1::GodotParams,
1604                fields,
1605            })
1606        }
1607        ("unreal", 2, "5.8", "fbx-importer", animsmith_core::SourceFormatV1::Fbx) => {
1608            let sample_rate = provenance
1609                .settings()
1610                .document_setting(Id::SampleRate)
1611                .ok_or(EngineImportAdviceError::InvalidV2Settings)?;
1612            let Value::SampleRate(value) = sample_rate.value() else {
1613                return Err(EngineImportAdviceError::InvalidV2Settings);
1614            };
1615            let (use_default, custom) = match value {
1616                animsmith_core::EngineSampleRateV2::Default30 => (true, None),
1617                animsmith_core::EngineSampleRateV2::SourceDetermined => (false, Some(0)),
1618                animsmith_core::EngineSampleRateV2::CustomHz(value) => (false, Some(*value)),
1619            };
1620            fields.push(EngineImportAdviceProjectionFieldV2 {
1621                key: "bUseDefaultSampleRate".into(),
1622                value: EngineImportAdviceProjectionValueV2::Boolean(use_default),
1623                value_origin: sample_rate.value_origin(),
1624            });
1625            if let Some(custom) = custom {
1626                fields.push(EngineImportAdviceProjectionFieldV2 {
1627                    key: "CustomSampleRate".into(),
1628                    value: EngineImportAdviceProjectionValueV2::UnsignedInteger(custom),
1629                    value_origin: sample_rate.value_origin(),
1630                });
1631            }
1632            fields.sort_by(|left, right| left.key.cmp(&right.key));
1633            Ok(EngineImportAdviceProjectionV2 {
1634                projection_kind: animsmith_core::ImportSettingProjectionKindV1::UnrealFbxImportData,
1635                fields,
1636            })
1637        }
1638        _ => Err(EngineImportAdviceError::UnsupportedProfileV2),
1639    }
1640}
1641
1642fn v2_refusal_name(reason: EngineImportAdviceRefusalReasonV2) -> &'static str {
1643    match reason {
1644        EngineImportAdviceRefusalReasonV2::DependencyClosureIncomplete => {
1645            "dependency_closure_incomplete"
1646        }
1647    }
1648}
1649
1650fn validate_v2_semantics(
1651    provenance: &animsmith_core::PredictionProvenanceV4,
1652    basis: &animsmith_core::EnginePredictionBasisV4,
1653    projection: Option<&EngineImportAdviceProjectionV2>,
1654    state: EngineImportAdviceStateV2,
1655    refusal_reason: Option<EngineImportAdviceRefusalReasonV2>,
1656) -> Result<(), EngineImportAdviceError> {
1657    validate_v2_profile(provenance)?;
1658    validate_v2_basis(provenance, basis)?;
1659    if basis != &v2_basis(provenance)? {
1660        return Err(EngineImportAdviceError::InvalidV2Projection);
1661    }
1662    let expected_projection = v2_projection(provenance)?;
1663    expected_projection.validate()?;
1664    if let Some(projection) = projection {
1665        projection.validate()?;
1666    }
1667    if provenance.dependency_closure().coverage().is_complete() {
1668        if state != EngineImportAdviceStateV2::Available
1669            || projection != Some(&expected_projection)
1670            || refusal_reason.is_some()
1671            || !provenance
1672                .profile()
1673                .accepts_format(provenance.source_format())
1674            || v2_supporting_sources(provenance).is_empty()
1675        {
1676            return Err(EngineImportAdviceError::InvalidV2Lifecycle);
1677        }
1678    } else if state != EngineImportAdviceStateV2::Refused
1679        || projection.is_some()
1680        || refusal_reason != Some(EngineImportAdviceRefusalReasonV2::DependencyClosureIncomplete)
1681    {
1682        return Err(EngineImportAdviceError::InvalidV2Lifecycle);
1683    }
1684    Ok(())
1685}
1686
1687fn validate_v2_profile(
1688    provenance: &animsmith_core::PredictionProvenanceV4,
1689) -> Result<(), EngineImportAdviceError> {
1690    let selection = provenance.profile().selection();
1691    let expected = match (
1692        selection.family(),
1693        selection.profile_revision(),
1694        selection.engine_version(),
1695        selection.importer(),
1696    ) {
1697        ("godot", 2, "4.7", "resource-importer-scene") => (
1698            "urn:animsmith:engine-profile:godot:2",
1699            GODOT_V2_FACTS,
1700            "godot_params",
1701        ),
1702        ("unreal", 2, "5.8", "fbx-importer") => (
1703            "urn:animsmith:engine-profile:unreal:2",
1704            UNREAL_V2_FACTS,
1705            "unreal_fbx_import_data",
1706        ),
1707        _ => return Err(EngineImportAdviceError::UnsupportedProfileV2),
1708    };
1709    if provenance.profile().fact_bundle_urn() != expected.0
1710        || provenance.profile().facts_identity().sha256() != expected.1.0
1711        || provenance.profile().facts_identity().bytes() != expected.1.1
1712        || !matches!(
1713            provenance
1714                .profile()
1715                .fact(animsmith_core::EngineFactIdV2::ImportSettingProjection),
1716            Some(fact)
1717                if matches!(fact.state(), animsmith_core::EngineFactStateV2::Known(animsmith_core::EngineFactValueV2::Token(token)) if token == expected.2)
1718        )
1719    {
1720        return Err(EngineImportAdviceError::InvalidV2Projection);
1721    }
1722    Ok(())
1723}
1724
1725fn validate_v2_basis(
1726    provenance: &animsmith_core::PredictionProvenanceV4,
1727    basis: &animsmith_core::EnginePredictionBasisV4,
1728) -> Result<(), EngineImportAdviceError> {
1729    // The core V4 validator is also the authoritative cross-contract checker.
1730    // A temporary unavailable facet lets us validate references without
1731    // serializing or retaining an EnginePredictionV4 in this advice contract.
1732    let facet = animsmith_core::EnginePredictionFacetV4::required_unavailable(
1733        animsmith_core::EvaluationScope::new(animsmith_core::EvaluationScopeCode::custom(
1734            IMPORT_SETTING_PROJECTION_SCOPE,
1735        )),
1736        basis.clone(),
1737        vec![animsmith_core::PredictionUnavailableReasonV2::ProfileFactUnknown],
1738    )
1739    .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?;
1740    let prediction =
1741        animsmith_core::EnginePredictionV4::new(provenance.identity().clone(), vec![facet])
1742            .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))?;
1743    prediction
1744        .validate_against_provenance(provenance)
1745        .map_err(|error| EngineImportAdviceError::InvalidV2Prediction(error.to_string()))
1746}
1747
1748fn validate_unity_document_against_provenance(
1749    provenance: &PredictionProvenanceV1,
1750    payload: &EngineImportAdvicePayloadV1,
1751) -> Result<(), EngineImportAdviceError> {
1752    use animsmith_core::{EngineSettingIdV1 as Id, EngineSettingValueV1 as Value};
1753    let Some(document) = payload.unity_document() else {
1754        return Err(EngineImportAdviceError::UnitySettingsMismatch);
1755    };
1756    let settings = provenance.settings();
1757    if settings.document_setting(Id::ConvertUnits) != Some(&Value::Boolean(document.convert_units))
1758        || settings.document_setting(Id::BakeAxisConversion)
1759            != Some(&Value::Boolean(document.bake_axis_conversion))
1760    {
1761        return Err(EngineImportAdviceError::UnitySettingsMismatch);
1762    }
1763    let generic = matches!(payload, EngineImportAdvicePayloadV1::UnityGeneric { .. });
1764    match (
1765        generic,
1766        document.root_motion_source.as_ref(),
1767        settings.document_setting(Id::RootMotionSource),
1768    ) {
1769        (true, Some(expected), Some(Value::SourceTransformPath(actual))) if expected == actual => {
1770            Ok(())
1771        }
1772        (false, None, None) => Ok(()),
1773        _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
1774    }
1775}
1776
1777fn validate_unity_settings_against_provenance(
1778    provenance: &PredictionProvenanceV1,
1779    clips: &[EngineImportAdviceClipV1],
1780    payload: &EngineImportAdvicePayloadV1,
1781) -> Result<(), EngineImportAdviceError> {
1782    use animsmith_core::{
1783        EngineBakeOrExtractV1 as Bake, EngineSettingIdV1 as Id, EngineSettingValueV1 as Value,
1784    };
1785    validate_unity_document_against_provenance(provenance, payload)?;
1786    let settings = provenance.settings();
1787    if settings.clips().len() != clips.len() {
1788        return Err(EngineImportAdviceError::UnitySettingsMismatch);
1789    }
1790    let mut used = vec![false; settings.clips().len()];
1791    for advice in payload.unity_clips() {
1792        let Some(clip) = clips
1793            .iter()
1794            .find(|clip| clip.normalized_clip_index == advice.normalized_clip_index)
1795        else {
1796            return Err(EngineImportAdviceError::UnitySettingsMismatch);
1797        };
1798        let Some((index, row)) = settings
1799            .clips()
1800            .iter()
1801            .enumerate()
1802            .find(|(index, row)| !used[*index] && row.clip_name() == clip.normalized_clip_name)
1803        else {
1804            return Err(EngineImportAdviceError::UnitySettingsMismatch);
1805        };
1806        used[index] = true;
1807        for (id, baked) in [
1808            (Id::RootRotation, advice.lock_root_rotation),
1809            (Id::RootPositionY, advice.lock_root_height_y),
1810            (Id::RootPositionXz, advice.lock_root_position_xz),
1811        ] {
1812            let expected = Value::BakeOrExtract(if baked { Bake::Bake } else { Bake::Extract });
1813            if row.setting(id) != Some(&expected) {
1814                return Err(EngineImportAdviceError::UnitySettingsMismatch);
1815            }
1816        }
1817    }
1818    Ok(())
1819}
1820
1821fn validate_profile(
1822    provenance: &PredictionProvenanceV1,
1823    payload: &EngineImportAdvicePayloadV1,
1824) -> Result<(), EngineImportAdviceError> {
1825    let selection = provenance.profile().selection();
1826    let facts = provenance.profile().facts_identity();
1827    let (family, revision, version, importer, identity) = match payload {
1828        EngineImportAdvicePayloadV1::UnityGeneric { .. } => (
1829            "unity-generic",
1830            1,
1831            "6000.3",
1832            "fbx-model-importer",
1833            UNITY_GENERIC_FACTS,
1834        ),
1835        EngineImportAdvicePayloadV1::UnityHumanoid { .. } => (
1836            "unity-humanoid",
1837            1,
1838            "6000.3",
1839            "fbx-model-importer",
1840            UNITY_HUMANOID_FACTS,
1841        ),
1842        EngineImportAdvicePayloadV1::Unreal => ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
1843        EngineImportAdvicePayloadV1::Godot => {
1844            ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS)
1845        }
1846    };
1847    if selection.family() != family
1848        || selection.profile_revision() != revision
1849        || selection.engine_version() != version
1850        || selection.importer() != importer
1851        || facts.sha256() != identity.0
1852        || facts.bytes() != identity.1
1853    {
1854        return Err(EngineImportAdviceError::ProfileMismatch);
1855    }
1856    Ok(())
1857}
1858
1859#[derive(Debug, Deserialize)]
1860#[serde(deny_unknown_fields)]
1861struct ToolSourceInputV1 {
1862    #[serde(default, deserialize_with = "deserialize_required_nullable")]
1863    revision: RequiredNullable<String>,
1864    #[serde(default, deserialize_with = "deserialize_required_nullable")]
1865    dirty: RequiredNullable<bool>,
1866}
1867
1868#[derive(Debug, Default)]
1869enum RequiredNullable<T> {
1870    #[default]
1871    Missing,
1872    Present(Option<T>),
1873}
1874
1875fn deserialize_required_nullable<'de, D, T>(
1876    deserializer: D,
1877) -> Result<RequiredNullable<T>, D::Error>
1878where
1879    D: Deserializer<'de>,
1880    T: Deserialize<'de>,
1881{
1882    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
1883}
1884
1885fn deserialize_present_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1886where
1887    D: Deserializer<'de>,
1888    T: Deserialize<'de>,
1889{
1890    T::deserialize(deserializer).map(Some)
1891}
1892
1893#[derive(Debug, Deserialize)]
1894#[serde(deny_unknown_fields)]
1895struct ToolInputV1 {
1896    name: String,
1897    version: String,
1898    source: ToolSourceInputV1,
1899}
1900
1901/// Staged bounded reader input for the standalone advice contract.
1902#[derive(Debug, Deserialize)]
1903#[serde(deny_unknown_fields)]
1904pub struct EngineImportAdviceInput {
1905    schema_version: u32,
1906    schema: String,
1907    tool: ToolInputV1,
1908    command: String,
1909    identity: EngineImportAdviceIdentityV1,
1910    prediction_provenance: Box<RawValue>,
1911    state: EngineImportAdviceStateV1,
1912    #[serde(default, deserialize_with = "deserialize_present_non_null")]
1913    refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1914    #[serde(deserialize_with = "deserialize_clips")]
1915    clips: Vec<EngineImportAdviceClipV1>,
1916    payload: EngineImportAdvicePayloadV1,
1917}
1918
1919impl EngineImportAdviceInput {
1920    /// Read at most 256 MiB before any JSON decoding.
1921    ///
1922    /// # Errors
1923    ///
1924    /// Returns a typed I/O, size, or JSON-shape error.
1925    pub fn read_from(reader: impl Read) -> Result<Self, EngineImportAdviceReadError> {
1926        Self::read_from_with_limit(reader, ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES)
1927    }
1928
1929    fn read_from_with_limit(
1930        reader: impl Read,
1931        limit: u64,
1932    ) -> Result<Self, EngineImportAdviceReadError> {
1933        let mut bytes = Vec::new();
1934        reader
1935            .take(limit.saturating_add(1))
1936            .read_to_end(&mut bytes)
1937            .map_err(|source| EngineImportAdviceReadError::Io { source })?;
1938        if bytes.len() as u64 > limit {
1939            return Err(EngineImportAdviceReadError::ReportTooLarge { limit });
1940        }
1941        serde_json::from_slice(&bytes)
1942            .map_err(|source| EngineImportAdviceReadError::InvalidJson { source })
1943    }
1944
1945    /// Validate identities, lifecycle, exact profile, and setting cross-links.
1946    ///
1947    /// # Errors
1948    ///
1949    /// Returns [`EngineImportAdviceError`] for a semantic contradiction.
1950    pub fn into_report(self) -> Result<EngineImportAdviceReadbackV1, EngineImportAdviceError> {
1951        if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
1952            || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
1953            || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1954        {
1955            return Err(EngineImportAdviceError::WrongHeader);
1956        }
1957        validate_tool(&self.tool)?;
1958        let prediction_provenance: PredictionProvenanceV1 =
1959            serde_json::from_str(self.prediction_provenance.get())
1960                .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
1961        let report = EngineImportAdviceReadbackV1 {
1962            tool: EngineImportAdviceToolReadbackV1 {
1963                name: self.tool.name,
1964                version: self.tool.version,
1965                revision: required_nullable_value(self.tool.source.revision),
1966                dirty: required_nullable_value(self.tool.source.dirty),
1967            },
1968            identity: self.identity,
1969            prediction_provenance,
1970            state: self.state,
1971            refusal_reason: self.refusal_reason,
1972            clips: self.clips,
1973            payload: self.payload,
1974        };
1975        report.validate()?;
1976        Ok(report)
1977    }
1978}
1979
1980/// Strict read-side representation of one import-advice document.
1981#[derive(Debug, Clone)]
1982pub struct EngineImportAdviceReadbackV1 {
1983    tool: EngineImportAdviceToolReadbackV1,
1984    identity: EngineImportAdviceIdentityV1,
1985    prediction_provenance: PredictionProvenanceV1,
1986    state: EngineImportAdviceStateV1,
1987    refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1988    clips: Vec<EngineImportAdviceClipV1>,
1989    payload: EngineImportAdvicePayloadV1,
1990}
1991
1992impl EngineImportAdviceReadbackV1 {
1993    /// Validated producer identity.
1994    pub const fn tool(&self) -> &EngineImportAdviceToolReadbackV1 {
1995        &self.tool
1996    }
1997    /// Canonical advice identity.
1998    pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
1999        &self.identity
2000    }
2001    /// Shared exact provenance.
2002    pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
2003        &self.prediction_provenance
2004    }
2005    /// Advice state.
2006    pub const fn state(&self) -> EngineImportAdviceStateV1 {
2007        self.state
2008    }
2009    /// Typed refusal reason.
2010    pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
2011        self.refusal_reason
2012    }
2013    /// Validated clip evidence.
2014    pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
2015        &self.clips
2016    }
2017    /// Validated profile-specific payload.
2018    pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
2019        &self.payload
2020    }
2021
2022    fn validate(&self) -> Result<(), EngineImportAdviceError> {
2023        let surrogate = EngineImportAdviceV1 {
2024            schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
2025            schema: ENGINE_IMPORT_ADVICE_V1_ID,
2026            tool: ToolInfo::animsmith(animsmith_core::ToolSource::new(None, None)),
2027            command: ENGINE_IMPORT_ADVICE_COMMAND,
2028            identity: self.identity.clone(),
2029            prediction_provenance: self.prediction_provenance.clone(),
2030            state: self.state,
2031            refusal_reason: self.refusal_reason,
2032            clips: self.clips.clone(),
2033            payload: self.payload.clone(),
2034        };
2035        surrogate.validate_without_identity()?;
2036        if surrogate.computed_identity() != self.identity.0 {
2037            return Err(EngineImportAdviceError::IdentityMismatch);
2038        }
2039        Ok(())
2040    }
2041}
2042
2043/// Validated producer metadata from strict readback.
2044#[derive(Debug, Clone, PartialEq, Eq)]
2045pub struct EngineImportAdviceToolReadbackV1 {
2046    name: String,
2047    version: String,
2048    revision: Option<String>,
2049    dirty: Option<bool>,
2050}
2051
2052impl EngineImportAdviceToolReadbackV1 {
2053    /// Producer name.
2054    pub fn name(&self) -> &str {
2055        &self.name
2056    }
2057    /// Producer semantic version.
2058    pub fn version(&self) -> &str {
2059        &self.version
2060    }
2061    /// Full source revision, when established.
2062    pub fn revision(&self) -> Option<&str> {
2063        self.revision.as_deref()
2064    }
2065    /// Dirty-worktree observation, when established.
2066    pub const fn dirty(&self) -> Option<bool> {
2067        self.dirty
2068    }
2069}
2070
2071struct BoundedSequenceVisitor<T> {
2072    element: PhantomData<fn() -> T>,
2073}
2074
2075impl<'de, T> Visitor<'de> for BoundedSequenceVisitor<T>
2076where
2077    T: Deserialize<'de>,
2078{
2079    type Value = Vec<T>;
2080    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2081        write!(formatter, "a sequence with at most {MAX_CLIPS} clip rows")
2082    }
2083    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
2084    where
2085        A: SeqAccess<'de>,
2086    {
2087        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX_CLIPS));
2088        while values.len() < MAX_CLIPS {
2089            let Some(value) = sequence.next_element()? else {
2090                return Ok(values);
2091            };
2092            values.push(value);
2093        }
2094        if sequence.next_element::<serde::de::IgnoredAny>()?.is_some() {
2095            return Err(A::Error::custom(EngineImportAdviceError::TooManyClips {
2096                found: MAX_CLIPS + 1,
2097                limit: MAX_CLIPS,
2098            }));
2099        }
2100        Ok(values)
2101    }
2102}
2103
2104fn deserialize_bounded_clips<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
2105where
2106    D: Deserializer<'de>,
2107    T: Deserialize<'de>,
2108{
2109    deserializer.deserialize_seq(BoundedSequenceVisitor {
2110        element: PhantomData,
2111    })
2112}
2113fn deserialize_clips<'de, D>(deserializer: D) -> Result<Vec<EngineImportAdviceClipV1>, D::Error>
2114where
2115    D: Deserializer<'de>,
2116{
2117    deserialize_bounded_clips(deserializer)
2118}
2119fn deserialize_unity_clips<'de, D>(deserializer: D) -> Result<Vec<UnityClipAdviceV1>, D::Error>
2120where
2121    D: Deserializer<'de>,
2122{
2123    deserialize_bounded_clips(deserializer)
2124}
2125
2126fn deserialize_v2_projection_fields<'de, D>(
2127    deserializer: D,
2128) -> Result<Vec<EngineImportAdviceProjectionFieldV2>, D::Error>
2129where
2130    D: Deserializer<'de>,
2131{
2132    deserialize_bounded_clips(deserializer)
2133}
2134
2135fn validate_tool(tool: &ToolInputV1) -> Result<(), EngineImportAdviceError> {
2136    if tool.name != "animsmith"
2137        || tool.name.len() > MAX_TEXT_BYTES
2138        || tool.version.len() > MAX_TEXT_BYTES
2139        || !is_schema_semver(&tool.version)
2140        || matches!(tool.source.revision, RequiredNullable::Missing)
2141        || matches!(tool.source.dirty, RequiredNullable::Missing)
2142        || matches!(
2143            &tool.source.revision,
2144            RequiredNullable::Present(Some(revision))
2145                if revision.len() != 40
2146                    || !revision.bytes().all(|byte| byte.is_ascii_hexdigit())
2147        )
2148    {
2149        return Err(EngineImportAdviceError::InvalidTool);
2150    }
2151    Ok(())
2152}
2153
2154fn required_nullable_value<T>(value: RequiredNullable<T>) -> Option<T> {
2155    match value {
2156        RequiredNullable::Present(value) => value,
2157        RequiredNullable::Missing => unreachable!("validated required nullable field"),
2158    }
2159}
2160
2161fn is_schema_semver(value: &str) -> bool {
2162    let without_build = match value.split_once('+') {
2163        Some((core, build))
2164            if !build.is_empty()
2165                && !build.contains('+')
2166                && build
2167                    .bytes()
2168                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2169        {
2170            core
2171        }
2172        Some(_) => return false,
2173        None => value,
2174    };
2175    let core = match without_build.split_once('-') {
2176        Some((core, pre))
2177            if !pre.is_empty()
2178                && pre
2179                    .bytes()
2180                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2181        {
2182            core
2183        }
2184        Some(_) => return false,
2185        None => without_build,
2186    };
2187    let parts = core.split('.').collect::<Vec<_>>();
2188    parts.len() == 3
2189        && parts.iter().all(|part| {
2190            !part.is_empty()
2191                && part.bytes().all(|byte| byte.is_ascii_digit())
2192                && (*part == "0" || !part.starts_with('0'))
2193        })
2194}
2195
2196struct AdviceEncoder {
2197    bytes: Vec<u8>,
2198}
2199impl AdviceEncoder {
2200    fn new(domain: &str) -> Self {
2201        let mut value = Self { bytes: Vec::new() };
2202        value.token(domain);
2203        value
2204    }
2205    fn token(&mut self, value: &str) {
2206        self.bytes
2207            .extend_from_slice(&(value.len() as u64).to_le_bytes());
2208        self.bytes.extend_from_slice(value.as_bytes());
2209    }
2210    fn count(&mut self, value: usize) {
2211        self.u64(value as u64);
2212    }
2213    fn u64(&mut self, value: u64) {
2214        self.bytes.extend_from_slice(&value.to_le_bytes());
2215    }
2216    fn bool(&mut self, value: bool) {
2217        self.bytes.push(u8::from(value));
2218    }
2219    fn f64(&mut self, value: f64) {
2220        self.bytes.extend_from_slice(&value.to_bits().to_le_bytes());
2221    }
2222}
2223
2224fn encode_identity(encoder: &mut AdviceEncoder, value: &InputIdentity) {
2225    encoder.token(value.sha256());
2226    encoder.u64(value.bytes());
2227}
2228fn encode_string_option(encoder: &mut AdviceEncoder, value: Option<&str>) {
2229    encoder.bool(value.is_some());
2230    if let Some(value) = value {
2231        encoder.token(value);
2232    }
2233}
2234fn encode_clip(encoder: &mut AdviceEncoder, clip: &EngineImportAdviceClipV1) {
2235    encoder.u64(clip.source_clip_index);
2236    encoder.u64(clip.normalized_clip_index);
2237    encoder.token(&clip.normalized_clip_name);
2238    match &clip.source_name {
2239        EngineImportAdviceSourceNameV1::Observed { value } => {
2240            encoder.token("observed");
2241            encoder.token(value);
2242        }
2243        EngineImportAdviceSourceNameV1::ProvenAbsent => encoder.token("proven_absent"),
2244        EngineImportAdviceSourceNameV1::Unavailable { reason } => {
2245            encoder.token("unavailable");
2246            encoder.token(source_reason_name(*reason));
2247        }
2248    }
2249    let evidence = &clip.evidence;
2250    encoder.f64(evidence.duration_s);
2251    encoder.bool(evidence.speed_mps.is_some());
2252    if let Some(value) = evidence.speed_mps {
2253        encoder.f64(value);
2254    }
2255    encoder.token(availability_name(evidence.speed_mps_availability));
2256    encoder.bool(evidence.loop_endpoint_mode.is_some());
2257    if let Some(value) = evidence.loop_endpoint_mode {
2258        encoder.token(value.as_str());
2259    }
2260    encoder.token(availability_name(evidence.loop_endpoint_mode_availability));
2261    encoder.bool(evidence.frame_grid.is_some());
2262    if let Some(value) = evidence.frame_grid {
2263        encoder.f64(value.fps);
2264        encoder.u64(u64::from(value.frame_intervals));
2265    }
2266    encoder.token(availability_name(evidence.frame_grid_availability));
2267    encoder.bool(evidence.looping.is_some());
2268    if let Some(value) = evidence.looping {
2269        encoder.bool(value);
2270    }
2271    for owner in [
2272        evidence.movement_owner_xz,
2273        evidence.movement_owner_y,
2274        evidence.movement_owner_yaw,
2275    ] {
2276        encoder.bool(owner.is_some());
2277        if let Some(owner) = owner {
2278            encoder.token(owner_name(owner));
2279        }
2280    }
2281}
2282
2283fn encode_payload(encoder: &mut AdviceEncoder, payload: &EngineImportAdvicePayloadV1) {
2284    match payload {
2285        EngineImportAdvicePayloadV1::UnityGeneric { document, clips } => {
2286            encoder.token("unity-generic");
2287            encode_unity_payload(encoder, document, clips);
2288        }
2289        EngineImportAdvicePayloadV1::UnityHumanoid { document, clips } => {
2290            encoder.token("unity-humanoid");
2291            encode_unity_payload(encoder, document, clips);
2292        }
2293        EngineImportAdvicePayloadV1::Unreal => encoder.token("unreal"),
2294        EngineImportAdvicePayloadV1::Godot => encoder.token("godot"),
2295    }
2296}
2297fn encode_unity_payload(
2298    encoder: &mut AdviceEncoder,
2299    document: &UnityDocumentAdviceV1,
2300    clips: &[UnityClipAdviceV1],
2301) {
2302    encoder.bool(document.convert_units);
2303    encoder.bool(document.bake_axis_conversion);
2304    encode_string_option(encoder, document.root_motion_source.as_deref());
2305    encoder.count(clips.len());
2306    for clip in clips {
2307        encoder.u64(clip.normalized_clip_index);
2308        encoder.bool(clip.lock_root_rotation);
2309        encoder.bool(clip.lock_root_height_y);
2310        encoder.bool(clip.lock_root_position_xz);
2311    }
2312}
2313
2314fn refusal_reason_name(reason: EngineImportAdviceRefusalReasonV1) -> &'static str {
2315    match reason {
2316        EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => "profile_settings_unmodeled",
2317        EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
2318            "raw_clip_inventory_incomplete"
2319        }
2320        EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable => "clip_identity_unavailable",
2321        EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch => "clip_identity_mismatch",
2322        EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => "measurement_unavailable",
2323    }
2324}
2325fn source_reason_name(reason: EngineImportAdviceSourceUnavailableReasonV1) -> &'static str {
2326    match reason {
2327        EngineImportAdviceSourceUnavailableReasonV1::Malformed => "malformed",
2328        EngineImportAdviceSourceUnavailableReasonV1::Discarded => "discarded",
2329        EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway => "normalized_away",
2330        EngineImportAdviceSourceUnavailableReasonV1::BakedAway => "baked_away",
2331        EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported => "loader_unsupported",
2332        EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded => {
2333            "projection_budget_exceeded"
2334        }
2335        EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable => "parser_unavailable",
2336    }
2337}
2338fn availability_name(value: MeasurementAvailability) -> &'static str {
2339    match value {
2340        MeasurementAvailability::Measured => "measured",
2341        MeasurementAvailability::NotApplicable => "not_applicable",
2342        MeasurementAvailability::Unavailable => "unavailable",
2343        _ => "unknown",
2344    }
2345}
2346fn owner_name(value: EngineImportAdviceMovementOwnerV1) -> &'static str {
2347    match value {
2348        EngineImportAdviceMovementOwnerV1::Gameplay => "gameplay",
2349        EngineImportAdviceMovementOwnerV1::Animation => "animation",
2350    }
2351}
2352
2353#[cfg(test)]
2354mod tests {
2355    use super::*;
2356    use std::io::Cursor;
2357
2358    #[test]
2359    fn staged_reader_accepts_byte_n_and_rejects_n_plus_one_before_json_decode() {
2360        let exact = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null"), 4);
2361        assert!(matches!(
2362            exact,
2363            Err(EngineImportAdviceReadError::InvalidJson { .. })
2364        ));
2365        let over = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null "), 4);
2366        assert!(matches!(
2367            over,
2368            Err(EngineImportAdviceReadError::ReportTooLarge { limit: 4 })
2369        ));
2370    }
2371}