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::{
8    BakeOrExtract, EngineProfile, ResolvedClipSettings, ResolvedProfile, SettingId, SettingValue,
9    project_prediction_provenance_v1,
10};
11use animsmith_core::measure::{
12    ClipMeasurements, FrameGridMeasurement, LoopEndpointMode, MeasurementAvailability,
13};
14use animsmith_core::{
15    Config, InputIdentity, LoadedSource, MetricGrids, MovementOwner, PredictionProvenanceV1,
16    RawSourceSetCoverageStateV1, SourceObservationStateV1, SourceSetCoverageStateV1, SourceTextV1,
17    SourceUnavailableReasonV1, ToolInfo, resolve_configured_roles,
18};
19use serde::de::{Error as _, SeqAccess, Visitor};
20use serde::{Deserialize, Deserializer, Serialize};
21use serde_json::value::RawValue;
22use std::collections::{BTreeMap, BTreeSet};
23use std::fmt;
24use std::io::Read;
25use std::marker::PhantomData;
26
27/// Immutable standalone engine-import-advice contract identity.
28pub const ENGINE_IMPORT_ADVICE_V1_ID: &str = "urn:animsmith:schema:engine-import-advice:1";
29/// Standalone engine-import-advice schema version.
30pub const ENGINE_IMPORT_ADVICE_SCHEMA_VERSION: u32 = 1;
31/// Stable command spelling carried by the standalone contract.
32pub const ENGINE_IMPORT_ADVICE_COMMAND: &str = "generate-import-advice";
33/// Maximum serialized bytes accepted by the strict V1 reader.
34pub const ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
35
36const MAX_CLIPS: usize = animsmith_core::RAW_SOURCE_V1_MAX_CLIPS;
37const MAX_TEXT_BYTES: usize = animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES;
38const IDENTITY_DOMAIN: &str = "urn:animsmith:engine-import-advice-preimage:1";
39const UNITY_GENERIC_FACTS: (&str, u64) = (
40    "97afc05a02f7f9a946c66945cb84669a8a67d4dae7bf642486b94f1de3a17dd4",
41    3097,
42);
43const UNITY_HUMANOID_FACTS: (&str, u64) = (
44    "43f53df9f26ca3a1248972566029609bcd6b63194cbca399789444622680a12a",
45    2847,
46);
47const UNREAL_FACTS: (&str, u64) = (
48    "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759",
49    2169,
50);
51const GODOT_FACTS: (&str, u64) = (
52    "e9c8316d1655c487b60dd35bbfc70289952c5fa12f4718f0be09c7e9a00fbe87",
53    1166,
54);
55
56/// Domain-separated canonical identity of one import-advice payload.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(transparent)]
59pub struct EngineImportAdviceIdentityV1(InputIdentity);
60
61impl EngineImportAdviceIdentityV1 {
62    /// SHA-256 plus canonical-preimage byte count.
63    pub const fn input_identity(&self) -> &InputIdentity {
64        &self.0
65    }
66}
67
68/// Whether requested advice could be emitted without guessing.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum EngineImportAdviceStateV1 {
72    /// Every V1 suggestion applicable to this profile was projected.
73    Available,
74    /// Advice was quarantined because required authority was unavailable.
75    Refused,
76}
77
78/// Exact reason why V1 emitted no clip suggestions.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum EngineImportAdviceRefusalReasonV1 {
82    /// This profile revision exposes no V1 setting vocabulary.
83    ProfileSettingsUnmodeled,
84    /// The raw source clip/take inventory was not exhaustive.
85    RawClipInventoryIncomplete,
86    /// A raw row lacked its normalized document index.
87    ClipIdentityUnavailable,
88    /// Raw rows, normalized clips, measurements, or settings disagreed.
89    ClipIdentityMismatch,
90    /// A required normalized measurement row was missing or malformed.
91    MeasurementUnavailable,
92}
93
94/// Stable raw-source reason retained for a source-name observation.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum EngineImportAdviceSourceUnavailableReasonV1 {
98    /// The source declaration was malformed.
99    Malformed,
100    /// The loader discarded the value.
101    Discarded,
102    /// Normalization removed the original value.
103    NormalizedAway,
104    /// Baking removed the original value.
105    BakedAway,
106    /// The loader does not model the source domain.
107    LoaderUnsupported,
108    /// The bounded projection stopped at its deterministic limit.
109    ProjectionBudgetExceeded,
110    /// The parser did not expose the value.
111    ParserUnavailable,
112}
113
114/// Bounded source animation/take name evidence.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
117pub enum EngineImportAdviceSourceNameV1 {
118    /// The source name was observed exactly.
119    Observed {
120        /// Bounded source spelling.
121        value: String,
122    },
123    /// Complete evidence proved that no source name exists.
124    ProvenAbsent,
125    /// The source name could not be established.
126    Unavailable {
127        /// Stable source-evidence reason.
128        reason: EngineImportAdviceSourceUnavailableReasonV1,
129    },
130}
131
132/// Project-declared owner of one world-movement component.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum EngineImportAdviceMovementOwnerV1 {
136    /// Gameplay/controller motion owns the component.
137    Gameplay,
138    /// Extracted animation root motion owns the component.
139    Animation,
140}
141
142/// Exact engine-neutral evidence retained beside one suggestion row.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct EngineImportAdviceClipEvidenceV1 {
146    duration_s: f64,
147    #[serde(
148        default,
149        skip_serializing_if = "Option::is_none",
150        deserialize_with = "deserialize_present_non_null"
151    )]
152    speed_mps: Option<f64>,
153    speed_mps_availability: MeasurementAvailability,
154    #[serde(
155        default,
156        skip_serializing_if = "Option::is_none",
157        deserialize_with = "deserialize_present_non_null"
158    )]
159    loop_endpoint_mode: Option<LoopEndpointMode>,
160    loop_endpoint_mode_availability: MeasurementAvailability,
161    #[serde(
162        default,
163        skip_serializing_if = "Option::is_none",
164        deserialize_with = "deserialize_present_non_null"
165    )]
166    frame_grid: Option<FrameGridMeasurement>,
167    frame_grid_availability: MeasurementAvailability,
168    #[serde(
169        rename = "loop",
170        default,
171        skip_serializing_if = "Option::is_none",
172        deserialize_with = "deserialize_present_non_null"
173    )]
174    looping: Option<bool>,
175    #[serde(
176        default,
177        skip_serializing_if = "Option::is_none",
178        deserialize_with = "deserialize_present_non_null"
179    )]
180    movement_owner_xz: Option<EngineImportAdviceMovementOwnerV1>,
181    #[serde(
182        default,
183        skip_serializing_if = "Option::is_none",
184        deserialize_with = "deserialize_present_non_null"
185    )]
186    movement_owner_y: Option<EngineImportAdviceMovementOwnerV1>,
187    #[serde(
188        default,
189        skip_serializing_if = "Option::is_none",
190        deserialize_with = "deserialize_present_non_null"
191    )]
192    movement_owner_yaw: Option<EngineImportAdviceMovementOwnerV1>,
193}
194
195impl EngineImportAdviceClipEvidenceV1 {
196    /// Measured normalized clip duration in seconds.
197    pub const fn duration_s(&self) -> f64 {
198        self.duration_s
199    }
200    /// Measured horizontal speed, when available.
201    pub const fn speed_mps(&self) -> Option<f64> {
202        self.speed_mps
203    }
204    /// Availability of [`Self::speed_mps`].
205    pub const fn speed_mps_availability(&self) -> MeasurementAvailability {
206        self.speed_mps_availability
207    }
208    /// Measured endpoint convention for an explicitly declared loop.
209    pub const fn loop_endpoint_mode(&self) -> Option<LoopEndpointMode> {
210        self.loop_endpoint_mode
211    }
212    /// Availability of [`Self::loop_endpoint_mode`].
213    pub const fn loop_endpoint_mode_availability(&self) -> MeasurementAvailability {
214        self.loop_endpoint_mode_availability
215    }
216    /// Validated declared-FPS grid, when measured.
217    pub const fn frame_grid(&self) -> Option<FrameGridMeasurement> {
218        self.frame_grid
219    }
220    /// Availability of [`Self::frame_grid`].
221    pub const fn frame_grid_availability(&self) -> MeasurementAvailability {
222        self.frame_grid_availability
223    }
224    /// Explicit project loop intent.
225    pub const fn looping(&self) -> Option<bool> {
226        self.looping
227    }
228    /// Explicit horizontal movement owner.
229    pub const fn movement_owner_xz(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
230        self.movement_owner_xz
231    }
232    /// Explicit vertical movement owner.
233    pub const fn movement_owner_y(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
234        self.movement_owner_y
235    }
236    /// Explicit yaw movement owner.
237    pub const fn movement_owner_yaw(&self) -> Option<EngineImportAdviceMovementOwnerV1> {
238        self.movement_owner_yaw
239    }
240
241    fn validate(&self) -> Result<(), EngineImportAdviceError> {
242        if !self.duration_s.is_finite()
243            || self.duration_s < 0.0
244            || self
245                .speed_mps
246                .is_some_and(|value| !value.is_finite() || value < 0.0)
247        {
248            return Err(EngineImportAdviceError::InvalidMeasurement);
249        }
250        validate_optional(self.speed_mps.is_some(), self.speed_mps_availability)?;
251        validate_optional(
252            self.loop_endpoint_mode.is_some(),
253            self.loop_endpoint_mode_availability,
254        )?;
255        let loop_endpoint_applicability_matches = match self.looping {
256            Some(true) => {
257                self.loop_endpoint_mode_availability != MeasurementAvailability::NotApplicable
258            }
259            Some(false) | None => {
260                self.loop_endpoint_mode_availability == MeasurementAvailability::NotApplicable
261            }
262        };
263        if !loop_endpoint_applicability_matches {
264            return Err(EngineImportAdviceError::InvalidMeasurement);
265        }
266        validate_optional(self.frame_grid.is_some(), self.frame_grid_availability)?;
267        if self.frame_grid.is_some_and(|grid| {
268            !grid.fps.is_finite() || grid.fps <= 0.0 || grid.frame_intervals == 0
269        }) {
270            return Err(EngineImportAdviceError::InvalidMeasurement);
271        }
272        Ok(())
273    }
274}
275
276/// One file-scoped normalized clip and its source/intent/measurement evidence.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct EngineImportAdviceClipV1 {
280    source_clip_index: u64,
281    normalized_clip_index: u64,
282    normalized_clip_name: String,
283    source_name: EngineImportAdviceSourceNameV1,
284    evidence: EngineImportAdviceClipEvidenceV1,
285}
286
287impl EngineImportAdviceClipV1 {
288    /// Stable source animation/take index.
289    pub const fn source_clip_index(&self) -> u64 {
290        self.source_clip_index
291    }
292    /// Exact normalized document clip index.
293    pub const fn normalized_clip_index(&self) -> u64 {
294        self.normalized_clip_index
295    }
296    /// Normalized document clip name.
297    pub fn normalized_clip_name(&self) -> &str {
298        &self.normalized_clip_name
299    }
300    /// Source name observation, distinct from the normalized name.
301    pub const fn source_name(&self) -> &EngineImportAdviceSourceNameV1 {
302        &self.source_name
303    }
304    /// Project-intent and measurement evidence.
305    pub const fn evidence(&self) -> &EngineImportAdviceClipEvidenceV1 {
306        &self.evidence
307    }
308
309    fn validate(&self, ordinal: usize) -> Result<(), EngineImportAdviceError> {
310        if self.source_clip_index != ordinal as u64
311            || self.normalized_clip_index >= MAX_CLIPS as u64
312            || self.normalized_clip_name.is_empty()
313            || self.normalized_clip_name.len() > MAX_TEXT_BYTES
314        {
315            return Err(EngineImportAdviceError::InvalidClipIdentity);
316        }
317        if let EngineImportAdviceSourceNameV1::Observed { value } = &self.source_name
318            && (value.is_empty() || value.len() > MAX_TEXT_BYTES)
319        {
320            return Err(EngineImportAdviceError::InvalidClipIdentity);
321        }
322        self.evidence.validate()
323    }
324}
325
326/// Exact Unity document-level importer suggestions.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct UnityDocumentAdviceV1 {
330    convert_units: bool,
331    bake_axis_conversion: bool,
332    #[serde(
333        default,
334        skip_serializing_if = "Option::is_none",
335        deserialize_with = "deserialize_present_non_null"
336    )]
337    root_motion_source: Option<String>,
338}
339
340impl UnityDocumentAdviceV1 {
341    /// Unity Model Importer Convert Units value.
342    pub const fn convert_units(&self) -> bool {
343        self.convert_units
344    }
345    /// Unity Model Importer Bake Axis Conversion value.
346    pub const fn bake_axis_conversion(&self) -> bool {
347        self.bake_axis_conversion
348    }
349    /// Unity Generic exact motion-node path, absent for Humanoid.
350    pub fn root_motion_source(&self) -> Option<&str> {
351        self.root_motion_source.as_deref()
352    }
353}
354
355/// Exact Unity per-clip importer suggestions.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(deny_unknown_fields)]
358pub struct UnityClipAdviceV1 {
359    normalized_clip_index: u64,
360    lock_root_rotation: bool,
361    lock_root_height_y: bool,
362    lock_root_position_xz: bool,
363}
364
365impl UnityClipAdviceV1 {
366    /// Normalized clip index linked to the root evidence row.
367    pub const fn normalized_clip_index(&self) -> u64 {
368        self.normalized_clip_index
369    }
370    /// Unity `ModelImporterClipAnimation.lockRootRotation`.
371    pub const fn lock_root_rotation(&self) -> bool {
372        self.lock_root_rotation
373    }
374    /// Unity `ModelImporterClipAnimation.lockRootHeightY`.
375    pub const fn lock_root_height_y(&self) -> bool {
376        self.lock_root_height_y
377    }
378    /// Unity `ModelImporterClipAnimation.lockRootPositionXZ`.
379    pub const fn lock_root_position_xz(&self) -> bool {
380        self.lock_root_position_xz
381    }
382}
383
384/// Profile-specific V1 advice payload.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(tag = "engine", rename_all = "kebab-case", deny_unknown_fields)]
387pub enum EngineImportAdvicePayloadV1 {
388    /// Unity Generic 6000.3 materialized settings.
389    UnityGeneric {
390        /// Document-level importer suggestions.
391        document: UnityDocumentAdviceV1,
392        /// Per-clip suggestions in normalized-index order.
393        #[serde(deserialize_with = "deserialize_unity_clips")]
394        clips: Vec<UnityClipAdviceV1>,
395    },
396    /// Unity Humanoid 6000.3 materialized settings.
397    UnityHumanoid {
398        /// Document-level importer suggestions.
399        document: UnityDocumentAdviceV1,
400        /// Per-clip suggestions in normalized-index order.
401        #[serde(deserialize_with = "deserialize_unity_clips")]
402        clips: Vec<UnityClipAdviceV1>,
403    },
404    /// Unreal 5.8 revision 1 has no modeled advice settings.
405    Unreal,
406    /// Godot 4.7 revision 1 has no modeled advice settings.
407    Godot,
408}
409
410impl EngineImportAdvicePayloadV1 {
411    /// Unity document suggestions, absent for unmodeled profiles.
412    pub const fn unity_document(&self) -> Option<&UnityDocumentAdviceV1> {
413        match self {
414            Self::UnityGeneric { document, .. } | Self::UnityHumanoid { document, .. } => {
415                Some(document)
416            }
417            Self::Unreal | Self::Godot => None,
418        }
419    }
420    /// Unity clip suggestions, empty for unmodeled profiles.
421    pub fn unity_clips(&self) -> &[UnityClipAdviceV1] {
422        match self {
423            Self::UnityGeneric { clips, .. } | Self::UnityHumanoid { clips, .. } => clips,
424            Self::Unreal | Self::Godot => &[],
425        }
426    }
427}
428
429/// Standalone producer envelope for `generate import-advice`.
430#[derive(Debug, Clone, Serialize)]
431pub struct EngineImportAdviceV1 {
432    schema_version: u32,
433    schema: &'static str,
434    tool: ToolInfo,
435    command: &'static str,
436    identity: EngineImportAdviceIdentityV1,
437    prediction_provenance: PredictionProvenanceV1,
438    state: EngineImportAdviceStateV1,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
441    clips: Vec<EngineImportAdviceClipV1>,
442    payload: EngineImportAdvicePayloadV1,
443}
444
445/// Import-advice producer or strict-reader contract failure.
446#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
447#[non_exhaustive]
448pub enum EngineImportAdviceError {
449    /// The selected profile is outside Unity/Unreal/Godot V1 advice.
450    #[error("generate import-advice requires an exact Unity, Unreal, or Godot V1 profile")]
451    UnsupportedProfile,
452    /// Engine-neutral project intent was not a valid core configuration.
453    #[error("invalid engine-import-advice configuration: {0}")]
454    InvalidConfig(String),
455    /// The standalone header was not the immutable V1 shape.
456    #[error("invalid engine-import-advice V1 header")]
457    WrongHeader,
458    /// Producer metadata violated the shared tool shape.
459    #[error("invalid engine-import-advice producer identity")]
460    InvalidTool,
461    /// Shared prediction provenance was invalid.
462    #[error("invalid prediction provenance: {0}")]
463    InvalidProvenance(String),
464    /// Exact profile tuple/facts did not match the payload variant.
465    #[error("engine-import-advice profile does not match its payload")]
466    ProfileMismatch,
467    /// Advice state, reason, or row presence contradicted the lifecycle.
468    #[error("invalid engine-import-advice lifecycle")]
469    InvalidLifecycle,
470    /// A source/normalized clip identity was malformed or duplicated.
471    #[error("invalid engine-import-advice clip identity")]
472    InvalidClipIdentity,
473    /// A retained measurement value/status pair was malformed.
474    #[error("invalid engine-import-advice measurement")]
475    InvalidMeasurement,
476    /// Materialized Unity settings did not match the advice projection.
477    #[error("engine-import-advice Unity settings do not match prediction provenance")]
478    UnitySettingsMismatch,
479    /// Too many clip rows were present.
480    #[error("engine-import-advice has {found} clips; limit is {limit}")]
481    TooManyClips {
482        /// First rejected row count.
483        found: usize,
484        /// Immutable V1 limit.
485        limit: usize,
486    },
487    /// Canonical advice identity did not match the payload.
488    #[error("engine-import-advice identity mismatch")]
489    IdentityMismatch,
490}
491
492/// Bounded-reader transport/shape failure.
493#[derive(Debug, thiserror::Error)]
494#[non_exhaustive]
495pub enum EngineImportAdviceReadError {
496    /// Reading serialized bytes failed.
497    #[error("cannot read engine-import-advice document: {source}")]
498    Io {
499        /// Underlying read failure.
500        #[source]
501        source: std::io::Error,
502    },
503    /// Serialized input exceeded the fixed pre-decode bound.
504    #[error("engine-import-advice document exceeds {limit} bytes")]
505    ReportTooLarge {
506        /// Immutable V1 byte limit.
507        limit: u64,
508    },
509    /// JSON shape or field vocabulary was invalid.
510    #[error("invalid engine-import-advice JSON: {source}")]
511    InvalidJson {
512        /// Strict JSON/serde failure.
513        #[source]
514        source: serde_json::Error,
515    },
516}
517
518impl EngineImportAdviceV1 {
519    /// Whether an immutable profile record has an exact V1 advice mapping.
520    ///
521    /// This static check requires no asset I/O. It is intentionally false for
522    /// Bevy and for any future profile revision until that exact record gains
523    /// a separately reviewed advice mapping.
524    pub fn supports_profile(profile: &EngineProfile) -> bool {
525        let selection = profile.selection();
526        let facts = profile.facts_identity();
527        [
528            (
529                "unity-generic",
530                1,
531                "6000.3",
532                "fbx-model-importer",
533                UNITY_GENERIC_FACTS,
534            ),
535            (
536                "unity-humanoid",
537                1,
538                "6000.3",
539                "fbx-model-importer",
540                UNITY_HUMANOID_FACTS,
541            ),
542            ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
543            ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS),
544        ]
545        .into_iter()
546        .any(|(family, revision, version, importer, identity)| {
547            selection.family() == family
548                && selection.profile_revision() == revision
549                && selection.engine_version() == version
550                && selection.importer() == importer
551                && facts.sha256() == identity.0
552                && facts.bytes() == identity.1
553        })
554    }
555
556    /// Construct one bounded result from same-load source, profile, intent,
557    /// and measurement evidence. Measurements and configured rig roles are
558    /// derived inside this boundary from `source`, so a caller cannot attach
559    /// a map computed from another normalized document.
560    ///
561    /// Exact Unreal and Godot V1 profiles produce a typed refusal because
562    /// those immutable revisions expose no setting vocabulary.
563    ///
564    /// # Errors
565    ///
566    /// Returns [`EngineImportAdviceError`] for an unsupported profile,
567    /// invalid same-load provenance, malformed materialized Unity settings,
568    /// or a contract invariant.
569    pub fn from_source(
570        tool: ToolInfo,
571        source: &LoadedSource,
572        profile: &ResolvedProfile,
573        config: &Config,
574    ) -> Result<Self, EngineImportAdviceError> {
575        config
576            .validate()
577            .map_err(|error| EngineImportAdviceError::InvalidConfig(error.to_string()))?;
578        if !Self::supports_profile(profile.profile()) {
579            return Err(EngineImportAdviceError::UnsupportedProfile);
580        }
581        let prediction_provenance = project_prediction_provenance_v1(profile, source)
582            .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
583        let family = profile.profile().selection().family();
584        let (payload, state, refusal_reason, clips) = match family {
585            "unreal" => (
586                EngineImportAdvicePayloadV1::Unreal,
587                EngineImportAdviceStateV1::Refused,
588                Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
589                Vec::new(),
590            ),
591            "godot" => (
592                EngineImportAdvicePayloadV1::Godot,
593                EngineImportAdviceStateV1::Refused,
594                Some(EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled),
595                Vec::new(),
596            ),
597            "unity-generic" | "unity-humanoid" => {
598                let generic = family == "unity-generic";
599                let document = unity_document(profile, generic)?;
600                let roles = resolve_configured_roles(&source.document().skeleton, &config.rig);
601                let grids = MetricGrids::new(source.document());
602                let measurements =
603                    animsmith_core::measure::measure_document(&grids, &roles, config);
604                match build_clip_evidence(source, &measurements, config) {
605                    Ok(clips) => match unity_clip_advice(profile, source.document(), &clips) {
606                        Ok(unity_clips) => {
607                            let payload = if generic {
608                                EngineImportAdvicePayloadV1::UnityGeneric {
609                                    document,
610                                    clips: unity_clips,
611                                }
612                            } else {
613                                EngineImportAdvicePayloadV1::UnityHumanoid {
614                                    document,
615                                    clips: unity_clips,
616                                }
617                            };
618                            (payload, EngineImportAdviceStateV1::Available, None, clips)
619                        }
620                        Err(EngineImportAdviceError::UnitySettingsMismatch) => {
621                            let payload = if generic {
622                                EngineImportAdvicePayloadV1::UnityGeneric {
623                                    document,
624                                    clips: Vec::new(),
625                                }
626                            } else {
627                                EngineImportAdvicePayloadV1::UnityHumanoid {
628                                    document,
629                                    clips: Vec::new(),
630                                }
631                            };
632                            (
633                                payload,
634                                EngineImportAdviceStateV1::Refused,
635                                Some(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch),
636                                Vec::new(),
637                            )
638                        }
639                        Err(error) => return Err(error),
640                    },
641                    Err(reason) => {
642                        let payload = if generic {
643                            EngineImportAdvicePayloadV1::UnityGeneric {
644                                document,
645                                clips: Vec::new(),
646                            }
647                        } else {
648                            EngineImportAdvicePayloadV1::UnityHumanoid {
649                                document,
650                                clips: Vec::new(),
651                            }
652                        };
653                        (
654                            payload,
655                            EngineImportAdviceStateV1::Refused,
656                            Some(reason),
657                            Vec::new(),
658                        )
659                    }
660                }
661            }
662            _ => return Err(EngineImportAdviceError::UnsupportedProfile),
663        };
664        let mut report = Self {
665            schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
666            schema: ENGINE_IMPORT_ADVICE_V1_ID,
667            tool,
668            command: ENGINE_IMPORT_ADVICE_COMMAND,
669            identity: EngineImportAdviceIdentityV1(InputIdentity::from_bytes(&[])),
670            prediction_provenance,
671            state,
672            refusal_reason,
673            clips,
674            payload,
675        };
676        report.validate_without_identity()?;
677        report.identity = EngineImportAdviceIdentityV1(report.computed_identity());
678        Ok(report)
679    }
680
681    /// Canonical advice identity.
682    pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
683        &self.identity
684    }
685    /// Exact shared profile/settings/source/closure provenance.
686    pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
687        &self.prediction_provenance
688    }
689    /// Advice availability state.
690    pub const fn state(&self) -> EngineImportAdviceStateV1 {
691        self.state
692    }
693    /// Refusal reason, present exactly when state is refused.
694    pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
695        self.refusal_reason
696    }
697    /// File-scoped clip evidence, empty for a refused result.
698    pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
699        &self.clips
700    }
701    /// Profile-specific setting projection or unmodeled payload.
702    pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
703        &self.payload
704    }
705
706    fn validate_without_identity(&self) -> Result<(), EngineImportAdviceError> {
707        if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
708            || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
709            || self.command != ENGINE_IMPORT_ADVICE_COMMAND
710        {
711            return Err(EngineImportAdviceError::WrongHeader);
712        }
713        self.prediction_provenance
714            .validate()
715            .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
716        validate_profile(&self.prediction_provenance, &self.payload)?;
717        if self.clips.len() > MAX_CLIPS {
718            return Err(EngineImportAdviceError::TooManyClips {
719                found: self.clips.len(),
720                limit: MAX_CLIPS,
721            });
722        }
723        let mut normalized = BTreeSet::new();
724        for (ordinal, clip) in self.clips.iter().enumerate() {
725            clip.validate(ordinal)?;
726            if !normalized.insert(clip.normalized_clip_index) {
727                return Err(EngineImportAdviceError::InvalidClipIdentity);
728            }
729        }
730        if normalized
731            .iter()
732            .copied()
733            .ne((0..self.clips.len()).map(|index| index as u64))
734        {
735            return Err(EngineImportAdviceError::InvalidClipIdentity);
736        }
737        let raw_clips_complete = self
738            .prediction_provenance
739            .raw_source()
740            .clips_coverage()
741            .state()
742            == RawSourceSetCoverageStateV1::Complete;
743        match (self.state, self.refusal_reason) {
744            (EngineImportAdviceStateV1::Available, None) => {
745                if !raw_clips_complete
746                    || matches!(
747                        self.payload,
748                        EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
749                    )
750                    || self.payload.unity_clips().len() != self.clips.len()
751                {
752                    return Err(EngineImportAdviceError::InvalidLifecycle);
753                }
754                for (normalized_index, advice) in self.payload.unity_clips().iter().enumerate() {
755                    if advice.normalized_clip_index != normalized_index as u64 {
756                        return Err(EngineImportAdviceError::InvalidClipIdentity);
757                    }
758                }
759                validate_unity_settings_against_provenance(
760                    &self.prediction_provenance,
761                    &self.clips,
762                    &self.payload,
763                )?;
764            }
765            (EngineImportAdviceStateV1::Refused, Some(reason)) => {
766                if !self.clips.is_empty() || !self.payload.unity_clips().is_empty() {
767                    return Err(EngineImportAdviceError::InvalidLifecycle);
768                }
769                let unmodeled = matches!(
770                    self.payload,
771                    EngineImportAdvicePayloadV1::Unreal | EngineImportAdvicePayloadV1::Godot
772                );
773                if (reason == EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled)
774                    != unmodeled
775                {
776                    return Err(EngineImportAdviceError::InvalidLifecycle);
777                }
778                if !unmodeled {
779                    let coverage_matches_reason = match reason {
780                        EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
781                            !raw_clips_complete
782                        }
783                        EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable
784                        | EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch
785                        | EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => {
786                            raw_clips_complete
787                        }
788                        EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => false,
789                    };
790                    if !coverage_matches_reason {
791                        return Err(EngineImportAdviceError::InvalidLifecycle);
792                    }
793                    validate_unity_document_against_provenance(
794                        &self.prediction_provenance,
795                        &self.payload,
796                    )?;
797                }
798            }
799            _ => return Err(EngineImportAdviceError::InvalidLifecycle),
800        }
801        Ok(())
802    }
803
804    fn computed_identity(&self) -> InputIdentity {
805        let mut encoder = AdviceEncoder::new(IDENTITY_DOMAIN);
806        encode_identity(
807            &mut encoder,
808            self.prediction_provenance.identity().input_identity(),
809        );
810        encoder.token(match self.state {
811            EngineImportAdviceStateV1::Available => "available",
812            EngineImportAdviceStateV1::Refused => "refused",
813        });
814        encoder.bool(self.refusal_reason.is_some());
815        if let Some(reason) = self.refusal_reason {
816            encoder.token(refusal_reason_name(reason));
817        }
818        encoder.count(self.clips.len());
819        for clip in &self.clips {
820            encode_clip(&mut encoder, clip);
821        }
822        encode_payload(&mut encoder, &self.payload);
823        InputIdentity::from_bytes(&encoder.bytes)
824    }
825}
826
827fn build_clip_evidence(
828    source: &LoadedSource,
829    measurements: &BTreeMap<String, ClipMeasurements>,
830    config: &Config,
831) -> Result<Vec<EngineImportAdviceClipV1>, EngineImportAdviceRefusalReasonV1> {
832    let raw = source.source_facts().clips();
833    if raw.coverage().state() != SourceSetCoverageStateV1::Complete {
834        return Err(EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete);
835    }
836    let document = source.document();
837    if raw.rows().len() != document.clips.len() || measurements.len() != document.clips.len() {
838        return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
839    }
840    let mut normalized = BTreeSet::new();
841    let mut rows = Vec::with_capacity(raw.rows().len());
842    for (ordinal, raw_clip) in raw.rows().iter().enumerate() {
843        if raw_clip.source_clip_index() != ordinal {
844            return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
845        }
846        let normalized_index = match raw_clip.normalized_clip_index().state() {
847            SourceObservationStateV1::Observed(value) => *value,
848            SourceObservationStateV1::ProvenAbsent | SourceObservationStateV1::Unavailable(_) => {
849                return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable);
850            }
851        };
852        if normalized_index >= document.clips.len() || !normalized.insert(normalized_index) {
853            return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
854        }
855        let clip = &document.clips[normalized_index];
856        let Some(measurement) = measurements.get(&clip.name) else {
857            return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
858        };
859        let expectations = config.expectations_for(&clip.name);
860        let evidence = EngineImportAdviceClipEvidenceV1 {
861            duration_s: measurement.duration_s,
862            speed_mps: measurement.speed_mps,
863            speed_mps_availability: measurement.speed_mps_availability,
864            loop_endpoint_mode: measurement.loop_endpoint_mode,
865            loop_endpoint_mode_availability: measurement.loop_endpoint_mode_availability,
866            frame_grid: measurement.frame_grid,
867            frame_grid_availability: measurement.frame_grid_availability,
868            looping: expectations.looping,
869            movement_owner_xz: expectations.movement_owner_xz.map(movement_owner),
870            movement_owner_y: expectations.movement_owner_y.map(movement_owner),
871            movement_owner_yaw: expectations.movement_owner_yaw.map(movement_owner),
872        };
873        if evidence.validate().is_err() {
874            return Err(EngineImportAdviceRefusalReasonV1::MeasurementUnavailable);
875        }
876        rows.push(EngineImportAdviceClipV1 {
877            source_clip_index: ordinal as u64,
878            normalized_clip_index: normalized_index as u64,
879            normalized_clip_name: clip.name.clone(),
880            source_name: source_name(raw_clip.source_name().state()),
881            evidence,
882        });
883    }
884    if normalized.len() != document.clips.len() {
885        return Err(EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch);
886    }
887    Ok(rows)
888}
889
890fn unity_document(
891    profile: &ResolvedProfile,
892    generic: bool,
893) -> Result<UnityDocumentAdviceV1, EngineImportAdviceError> {
894    let convert_units = bool_setting(profile.document_settings().get(&SettingId::ConvertUnits))?;
895    let bake_axis_conversion = bool_setting(
896        profile
897            .document_settings()
898            .get(&SettingId::BakeAxisConversion),
899    )?;
900    let root_motion_source = if generic {
901        match profile
902            .document_settings()
903            .get(&SettingId::RootMotionSource)
904        {
905            Some(SettingValue::SourceTransformPath(value)) => Some(value.clone()),
906            _ => return Err(EngineImportAdviceError::UnitySettingsMismatch),
907        }
908    } else {
909        None
910    };
911    Ok(UnityDocumentAdviceV1 {
912        convert_units,
913        bake_axis_conversion,
914        root_motion_source,
915    })
916}
917
918fn unity_clip_advice(
919    profile: &ResolvedProfile,
920    document: &animsmith_core::Document,
921    clips: &[EngineImportAdviceClipV1],
922) -> Result<Vec<UnityClipAdviceV1>, EngineImportAdviceError> {
923    let settings = settings_by_document_index(profile.clip_settings(), document)?;
924    let mut advice = clips
925        .iter()
926        .map(|clip| {
927            let row = settings
928                .get(clip.normalized_clip_index as usize)
929                .ok_or(EngineImportAdviceError::UnitySettingsMismatch)?;
930            Ok(UnityClipAdviceV1 {
931                normalized_clip_index: clip.normalized_clip_index,
932                lock_root_rotation: bake_setting(row.settings().get(&SettingId::RootRotation))?,
933                lock_root_height_y: bake_setting(row.settings().get(&SettingId::RootPositionY))?,
934                lock_root_position_xz: bake_setting(
935                    row.settings().get(&SettingId::RootPositionXz),
936                )?,
937            })
938        })
939        .collect::<Result<Vec<_>, _>>()?;
940    advice.sort_by_key(|clip| clip.normalized_clip_index);
941    Ok(advice)
942}
943
944fn settings_by_document_index<'a>(
945    settings: &'a [ResolvedClipSettings],
946    document: &animsmith_core::Document,
947) -> Result<Vec<&'a ResolvedClipSettings>, EngineImportAdviceError> {
948    if settings.len() != document.clips.len() {
949        return Err(EngineImportAdviceError::UnitySettingsMismatch);
950    }
951    let mut used = vec![false; settings.len()];
952    document
953        .clips
954        .iter()
955        .map(|clip| {
956            let Some((index, row)) = settings
957                .iter()
958                .enumerate()
959                .find(|(index, row)| !used[*index] && row.clip_name() == clip.name)
960            else {
961                return Err(EngineImportAdviceError::UnitySettingsMismatch);
962            };
963            used[index] = true;
964            Ok(row)
965        })
966        .collect()
967}
968
969fn bool_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
970    match value {
971        Some(SettingValue::Boolean(value)) => Ok(*value),
972        _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
973    }
974}
975
976fn bake_setting(value: Option<&SettingValue>) -> Result<bool, EngineImportAdviceError> {
977    match value {
978        Some(SettingValue::BakeOrExtract(BakeOrExtract::Bake)) => Ok(true),
979        Some(SettingValue::BakeOrExtract(BakeOrExtract::Extract)) => Ok(false),
980        _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
981    }
982}
983
984fn movement_owner(value: MovementOwner) -> EngineImportAdviceMovementOwnerV1 {
985    match value {
986        MovementOwner::Gameplay => EngineImportAdviceMovementOwnerV1::Gameplay,
987        MovementOwner::Animation => EngineImportAdviceMovementOwnerV1::Animation,
988    }
989}
990
991fn source_name(state: &SourceObservationStateV1<SourceTextV1>) -> EngineImportAdviceSourceNameV1 {
992    match state {
993        SourceObservationStateV1::Observed(value) => EngineImportAdviceSourceNameV1::Observed {
994            value: value.as_str().to_owned(),
995        },
996        SourceObservationStateV1::ProvenAbsent => EngineImportAdviceSourceNameV1::ProvenAbsent,
997        SourceObservationStateV1::Unavailable(reason) => {
998            EngineImportAdviceSourceNameV1::Unavailable {
999                reason: source_reason(*reason),
1000            }
1001        }
1002    }
1003}
1004
1005fn source_reason(reason: SourceUnavailableReasonV1) -> EngineImportAdviceSourceUnavailableReasonV1 {
1006    match reason {
1007        SourceUnavailableReasonV1::Malformed => {
1008            EngineImportAdviceSourceUnavailableReasonV1::Malformed
1009        }
1010        SourceUnavailableReasonV1::Discarded => {
1011            EngineImportAdviceSourceUnavailableReasonV1::Discarded
1012        }
1013        SourceUnavailableReasonV1::NormalizedAway => {
1014            EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway
1015        }
1016        SourceUnavailableReasonV1::BakedAway => {
1017            EngineImportAdviceSourceUnavailableReasonV1::BakedAway
1018        }
1019        SourceUnavailableReasonV1::LoaderUnsupported => {
1020            EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported
1021        }
1022        SourceUnavailableReasonV1::ProjectionBudgetExceeded => {
1023            EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded
1024        }
1025        SourceUnavailableReasonV1::ParserUnavailable => {
1026            EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable
1027        }
1028    }
1029}
1030
1031fn validate_optional(
1032    present: bool,
1033    availability: MeasurementAvailability,
1034) -> Result<(), EngineImportAdviceError> {
1035    if matches!(availability, MeasurementAvailability::Measured) != present {
1036        return Err(EngineImportAdviceError::InvalidMeasurement);
1037    }
1038    Ok(())
1039}
1040
1041fn validate_unity_document_against_provenance(
1042    provenance: &PredictionProvenanceV1,
1043    payload: &EngineImportAdvicePayloadV1,
1044) -> Result<(), EngineImportAdviceError> {
1045    use animsmith_core::{EngineSettingIdV1 as Id, EngineSettingValueV1 as Value};
1046    let Some(document) = payload.unity_document() else {
1047        return Err(EngineImportAdviceError::UnitySettingsMismatch);
1048    };
1049    let settings = provenance.settings();
1050    if settings.document_setting(Id::ConvertUnits) != Some(&Value::Boolean(document.convert_units))
1051        || settings.document_setting(Id::BakeAxisConversion)
1052            != Some(&Value::Boolean(document.bake_axis_conversion))
1053    {
1054        return Err(EngineImportAdviceError::UnitySettingsMismatch);
1055    }
1056    let generic = matches!(payload, EngineImportAdvicePayloadV1::UnityGeneric { .. });
1057    match (
1058        generic,
1059        document.root_motion_source.as_ref(),
1060        settings.document_setting(Id::RootMotionSource),
1061    ) {
1062        (true, Some(expected), Some(Value::SourceTransformPath(actual))) if expected == actual => {
1063            Ok(())
1064        }
1065        (false, None, None) => Ok(()),
1066        _ => Err(EngineImportAdviceError::UnitySettingsMismatch),
1067    }
1068}
1069
1070fn validate_unity_settings_against_provenance(
1071    provenance: &PredictionProvenanceV1,
1072    clips: &[EngineImportAdviceClipV1],
1073    payload: &EngineImportAdvicePayloadV1,
1074) -> Result<(), EngineImportAdviceError> {
1075    use animsmith_core::{
1076        EngineBakeOrExtractV1 as Bake, EngineSettingIdV1 as Id, EngineSettingValueV1 as Value,
1077    };
1078    validate_unity_document_against_provenance(provenance, payload)?;
1079    let settings = provenance.settings();
1080    if settings.clips().len() != clips.len() {
1081        return Err(EngineImportAdviceError::UnitySettingsMismatch);
1082    }
1083    let mut used = vec![false; settings.clips().len()];
1084    for advice in payload.unity_clips() {
1085        let Some(clip) = clips
1086            .iter()
1087            .find(|clip| clip.normalized_clip_index == advice.normalized_clip_index)
1088        else {
1089            return Err(EngineImportAdviceError::UnitySettingsMismatch);
1090        };
1091        let Some((index, row)) = settings
1092            .clips()
1093            .iter()
1094            .enumerate()
1095            .find(|(index, row)| !used[*index] && row.clip_name() == clip.normalized_clip_name)
1096        else {
1097            return Err(EngineImportAdviceError::UnitySettingsMismatch);
1098        };
1099        used[index] = true;
1100        for (id, baked) in [
1101            (Id::RootRotation, advice.lock_root_rotation),
1102            (Id::RootPositionY, advice.lock_root_height_y),
1103            (Id::RootPositionXz, advice.lock_root_position_xz),
1104        ] {
1105            let expected = Value::BakeOrExtract(if baked { Bake::Bake } else { Bake::Extract });
1106            if row.setting(id) != Some(&expected) {
1107                return Err(EngineImportAdviceError::UnitySettingsMismatch);
1108            }
1109        }
1110    }
1111    Ok(())
1112}
1113
1114fn validate_profile(
1115    provenance: &PredictionProvenanceV1,
1116    payload: &EngineImportAdvicePayloadV1,
1117) -> Result<(), EngineImportAdviceError> {
1118    let selection = provenance.profile().selection();
1119    let facts = provenance.profile().facts_identity();
1120    let (family, revision, version, importer, identity) = match payload {
1121        EngineImportAdvicePayloadV1::UnityGeneric { .. } => (
1122            "unity-generic",
1123            1,
1124            "6000.3",
1125            "fbx-model-importer",
1126            UNITY_GENERIC_FACTS,
1127        ),
1128        EngineImportAdvicePayloadV1::UnityHumanoid { .. } => (
1129            "unity-humanoid",
1130            1,
1131            "6000.3",
1132            "fbx-model-importer",
1133            UNITY_HUMANOID_FACTS,
1134        ),
1135        EngineImportAdvicePayloadV1::Unreal => ("unreal", 1, "5.8", "fbx-importer", UNREAL_FACTS),
1136        EngineImportAdvicePayloadV1::Godot => {
1137            ("godot", 1, "4.7", "resource-importer-scene", GODOT_FACTS)
1138        }
1139    };
1140    if selection.family() != family
1141        || selection.profile_revision() != revision
1142        || selection.engine_version() != version
1143        || selection.importer() != importer
1144        || facts.sha256() != identity.0
1145        || facts.bytes() != identity.1
1146    {
1147        return Err(EngineImportAdviceError::ProfileMismatch);
1148    }
1149    Ok(())
1150}
1151
1152#[derive(Debug, Deserialize)]
1153#[serde(deny_unknown_fields)]
1154struct ToolSourceInputV1 {
1155    #[serde(default, deserialize_with = "deserialize_required_nullable")]
1156    revision: RequiredNullable<String>,
1157    #[serde(default, deserialize_with = "deserialize_required_nullable")]
1158    dirty: RequiredNullable<bool>,
1159}
1160
1161#[derive(Debug, Default)]
1162enum RequiredNullable<T> {
1163    #[default]
1164    Missing,
1165    Present(Option<T>),
1166}
1167
1168fn deserialize_required_nullable<'de, D, T>(
1169    deserializer: D,
1170) -> Result<RequiredNullable<T>, D::Error>
1171where
1172    D: Deserializer<'de>,
1173    T: Deserialize<'de>,
1174{
1175    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
1176}
1177
1178fn deserialize_present_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1179where
1180    D: Deserializer<'de>,
1181    T: Deserialize<'de>,
1182{
1183    T::deserialize(deserializer).map(Some)
1184}
1185
1186#[derive(Debug, Deserialize)]
1187#[serde(deny_unknown_fields)]
1188struct ToolInputV1 {
1189    name: String,
1190    version: String,
1191    source: ToolSourceInputV1,
1192}
1193
1194/// Staged bounded reader input for the standalone advice contract.
1195#[derive(Debug, Deserialize)]
1196#[serde(deny_unknown_fields)]
1197pub struct EngineImportAdviceInput {
1198    schema_version: u32,
1199    schema: String,
1200    tool: ToolInputV1,
1201    command: String,
1202    identity: EngineImportAdviceIdentityV1,
1203    prediction_provenance: Box<RawValue>,
1204    state: EngineImportAdviceStateV1,
1205    #[serde(default, deserialize_with = "deserialize_present_non_null")]
1206    refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1207    #[serde(deserialize_with = "deserialize_clips")]
1208    clips: Vec<EngineImportAdviceClipV1>,
1209    payload: EngineImportAdvicePayloadV1,
1210}
1211
1212impl EngineImportAdviceInput {
1213    /// Read at most 256 MiB before any JSON decoding.
1214    ///
1215    /// # Errors
1216    ///
1217    /// Returns a typed I/O, size, or JSON-shape error.
1218    pub fn read_from(reader: impl Read) -> Result<Self, EngineImportAdviceReadError> {
1219        Self::read_from_with_limit(reader, ENGINE_IMPORT_ADVICE_V1_MAX_REPORT_BYTES)
1220    }
1221
1222    fn read_from_with_limit(
1223        reader: impl Read,
1224        limit: u64,
1225    ) -> Result<Self, EngineImportAdviceReadError> {
1226        let mut bytes = Vec::new();
1227        reader
1228            .take(limit.saturating_add(1))
1229            .read_to_end(&mut bytes)
1230            .map_err(|source| EngineImportAdviceReadError::Io { source })?;
1231        if bytes.len() as u64 > limit {
1232            return Err(EngineImportAdviceReadError::ReportTooLarge { limit });
1233        }
1234        serde_json::from_slice(&bytes)
1235            .map_err(|source| EngineImportAdviceReadError::InvalidJson { source })
1236    }
1237
1238    /// Validate identities, lifecycle, exact profile, and setting cross-links.
1239    ///
1240    /// # Errors
1241    ///
1242    /// Returns [`EngineImportAdviceError`] for a semantic contradiction.
1243    pub fn into_report(self) -> Result<EngineImportAdviceReadbackV1, EngineImportAdviceError> {
1244        if self.schema_version != ENGINE_IMPORT_ADVICE_SCHEMA_VERSION
1245            || self.schema != ENGINE_IMPORT_ADVICE_V1_ID
1246            || self.command != ENGINE_IMPORT_ADVICE_COMMAND
1247        {
1248            return Err(EngineImportAdviceError::WrongHeader);
1249        }
1250        validate_tool(&self.tool)?;
1251        let prediction_provenance: PredictionProvenanceV1 =
1252            serde_json::from_str(self.prediction_provenance.get())
1253                .map_err(|error| EngineImportAdviceError::InvalidProvenance(error.to_string()))?;
1254        let report = EngineImportAdviceReadbackV1 {
1255            tool: EngineImportAdviceToolReadbackV1 {
1256                name: self.tool.name,
1257                version: self.tool.version,
1258                revision: required_nullable_value(self.tool.source.revision),
1259                dirty: required_nullable_value(self.tool.source.dirty),
1260            },
1261            identity: self.identity,
1262            prediction_provenance,
1263            state: self.state,
1264            refusal_reason: self.refusal_reason,
1265            clips: self.clips,
1266            payload: self.payload,
1267        };
1268        report.validate()?;
1269        Ok(report)
1270    }
1271}
1272
1273/// Strict read-side representation of one import-advice document.
1274#[derive(Debug, Clone)]
1275pub struct EngineImportAdviceReadbackV1 {
1276    tool: EngineImportAdviceToolReadbackV1,
1277    identity: EngineImportAdviceIdentityV1,
1278    prediction_provenance: PredictionProvenanceV1,
1279    state: EngineImportAdviceStateV1,
1280    refusal_reason: Option<EngineImportAdviceRefusalReasonV1>,
1281    clips: Vec<EngineImportAdviceClipV1>,
1282    payload: EngineImportAdvicePayloadV1,
1283}
1284
1285impl EngineImportAdviceReadbackV1 {
1286    /// Validated producer identity.
1287    pub const fn tool(&self) -> &EngineImportAdviceToolReadbackV1 {
1288        &self.tool
1289    }
1290    /// Canonical advice identity.
1291    pub const fn identity(&self) -> &EngineImportAdviceIdentityV1 {
1292        &self.identity
1293    }
1294    /// Shared exact provenance.
1295    pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
1296        &self.prediction_provenance
1297    }
1298    /// Advice state.
1299    pub const fn state(&self) -> EngineImportAdviceStateV1 {
1300        self.state
1301    }
1302    /// Typed refusal reason.
1303    pub const fn refusal_reason(&self) -> Option<EngineImportAdviceRefusalReasonV1> {
1304        self.refusal_reason
1305    }
1306    /// Validated clip evidence.
1307    pub fn clips(&self) -> &[EngineImportAdviceClipV1] {
1308        &self.clips
1309    }
1310    /// Validated profile-specific payload.
1311    pub const fn payload(&self) -> &EngineImportAdvicePayloadV1 {
1312        &self.payload
1313    }
1314
1315    fn validate(&self) -> Result<(), EngineImportAdviceError> {
1316        let surrogate = EngineImportAdviceV1 {
1317            schema_version: ENGINE_IMPORT_ADVICE_SCHEMA_VERSION,
1318            schema: ENGINE_IMPORT_ADVICE_V1_ID,
1319            tool: ToolInfo::animsmith(animsmith_core::ToolSource::new(None, None)),
1320            command: ENGINE_IMPORT_ADVICE_COMMAND,
1321            identity: self.identity.clone(),
1322            prediction_provenance: self.prediction_provenance.clone(),
1323            state: self.state,
1324            refusal_reason: self.refusal_reason,
1325            clips: self.clips.clone(),
1326            payload: self.payload.clone(),
1327        };
1328        surrogate.validate_without_identity()?;
1329        if surrogate.computed_identity() != self.identity.0 {
1330            return Err(EngineImportAdviceError::IdentityMismatch);
1331        }
1332        Ok(())
1333    }
1334}
1335
1336/// Validated producer metadata from strict readback.
1337#[derive(Debug, Clone, PartialEq, Eq)]
1338pub struct EngineImportAdviceToolReadbackV1 {
1339    name: String,
1340    version: String,
1341    revision: Option<String>,
1342    dirty: Option<bool>,
1343}
1344
1345impl EngineImportAdviceToolReadbackV1 {
1346    /// Producer name.
1347    pub fn name(&self) -> &str {
1348        &self.name
1349    }
1350    /// Producer semantic version.
1351    pub fn version(&self) -> &str {
1352        &self.version
1353    }
1354    /// Full source revision, when established.
1355    pub fn revision(&self) -> Option<&str> {
1356        self.revision.as_deref()
1357    }
1358    /// Dirty-worktree observation, when established.
1359    pub const fn dirty(&self) -> Option<bool> {
1360        self.dirty
1361    }
1362}
1363
1364struct BoundedSequenceVisitor<T> {
1365    element: PhantomData<fn() -> T>,
1366}
1367
1368impl<'de, T> Visitor<'de> for BoundedSequenceVisitor<T>
1369where
1370    T: Deserialize<'de>,
1371{
1372    type Value = Vec<T>;
1373    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1374        write!(formatter, "a sequence with at most {MAX_CLIPS} clip rows")
1375    }
1376    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1377    where
1378        A: SeqAccess<'de>,
1379    {
1380        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX_CLIPS));
1381        while values.len() < MAX_CLIPS {
1382            let Some(value) = sequence.next_element()? else {
1383                return Ok(values);
1384            };
1385            values.push(value);
1386        }
1387        if sequence.next_element::<serde::de::IgnoredAny>()?.is_some() {
1388            return Err(A::Error::custom(EngineImportAdviceError::TooManyClips {
1389                found: MAX_CLIPS + 1,
1390                limit: MAX_CLIPS,
1391            }));
1392        }
1393        Ok(values)
1394    }
1395}
1396
1397fn deserialize_bounded_clips<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
1398where
1399    D: Deserializer<'de>,
1400    T: Deserialize<'de>,
1401{
1402    deserializer.deserialize_seq(BoundedSequenceVisitor {
1403        element: PhantomData,
1404    })
1405}
1406fn deserialize_clips<'de, D>(deserializer: D) -> Result<Vec<EngineImportAdviceClipV1>, D::Error>
1407where
1408    D: Deserializer<'de>,
1409{
1410    deserialize_bounded_clips(deserializer)
1411}
1412fn deserialize_unity_clips<'de, D>(deserializer: D) -> Result<Vec<UnityClipAdviceV1>, D::Error>
1413where
1414    D: Deserializer<'de>,
1415{
1416    deserialize_bounded_clips(deserializer)
1417}
1418
1419fn validate_tool(tool: &ToolInputV1) -> Result<(), EngineImportAdviceError> {
1420    if tool.name != "animsmith"
1421        || tool.name.len() > MAX_TEXT_BYTES
1422        || tool.version.len() > MAX_TEXT_BYTES
1423        || !is_schema_semver(&tool.version)
1424        || matches!(tool.source.revision, RequiredNullable::Missing)
1425        || matches!(tool.source.dirty, RequiredNullable::Missing)
1426        || matches!(
1427            &tool.source.revision,
1428            RequiredNullable::Present(Some(revision))
1429                if revision.len() != 40
1430                    || !revision.bytes().all(|byte| byte.is_ascii_hexdigit())
1431        )
1432    {
1433        return Err(EngineImportAdviceError::InvalidTool);
1434    }
1435    Ok(())
1436}
1437
1438fn required_nullable_value<T>(value: RequiredNullable<T>) -> Option<T> {
1439    match value {
1440        RequiredNullable::Present(value) => value,
1441        RequiredNullable::Missing => unreachable!("validated required nullable field"),
1442    }
1443}
1444
1445fn is_schema_semver(value: &str) -> bool {
1446    let without_build = match value.split_once('+') {
1447        Some((core, build))
1448            if !build.is_empty()
1449                && !build.contains('+')
1450                && build
1451                    .bytes()
1452                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
1453        {
1454            core
1455        }
1456        Some(_) => return false,
1457        None => value,
1458    };
1459    let core = match without_build.split_once('-') {
1460        Some((core, pre))
1461            if !pre.is_empty()
1462                && pre
1463                    .bytes()
1464                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
1465        {
1466            core
1467        }
1468        Some(_) => return false,
1469        None => without_build,
1470    };
1471    let parts = core.split('.').collect::<Vec<_>>();
1472    parts.len() == 3
1473        && parts.iter().all(|part| {
1474            !part.is_empty()
1475                && part.bytes().all(|byte| byte.is_ascii_digit())
1476                && (*part == "0" || !part.starts_with('0'))
1477        })
1478}
1479
1480struct AdviceEncoder {
1481    bytes: Vec<u8>,
1482}
1483impl AdviceEncoder {
1484    fn new(domain: &str) -> Self {
1485        let mut value = Self { bytes: Vec::new() };
1486        value.token(domain);
1487        value
1488    }
1489    fn token(&mut self, value: &str) {
1490        self.bytes
1491            .extend_from_slice(&(value.len() as u64).to_le_bytes());
1492        self.bytes.extend_from_slice(value.as_bytes());
1493    }
1494    fn count(&mut self, value: usize) {
1495        self.u64(value as u64);
1496    }
1497    fn u64(&mut self, value: u64) {
1498        self.bytes.extend_from_slice(&value.to_le_bytes());
1499    }
1500    fn bool(&mut self, value: bool) {
1501        self.bytes.push(u8::from(value));
1502    }
1503    fn f64(&mut self, value: f64) {
1504        self.bytes.extend_from_slice(&value.to_bits().to_le_bytes());
1505    }
1506}
1507
1508fn encode_identity(encoder: &mut AdviceEncoder, value: &InputIdentity) {
1509    encoder.token(value.sha256());
1510    encoder.u64(value.bytes());
1511}
1512fn encode_string_option(encoder: &mut AdviceEncoder, value: Option<&str>) {
1513    encoder.bool(value.is_some());
1514    if let Some(value) = value {
1515        encoder.token(value);
1516    }
1517}
1518fn encode_clip(encoder: &mut AdviceEncoder, clip: &EngineImportAdviceClipV1) {
1519    encoder.u64(clip.source_clip_index);
1520    encoder.u64(clip.normalized_clip_index);
1521    encoder.token(&clip.normalized_clip_name);
1522    match &clip.source_name {
1523        EngineImportAdviceSourceNameV1::Observed { value } => {
1524            encoder.token("observed");
1525            encoder.token(value);
1526        }
1527        EngineImportAdviceSourceNameV1::ProvenAbsent => encoder.token("proven_absent"),
1528        EngineImportAdviceSourceNameV1::Unavailable { reason } => {
1529            encoder.token("unavailable");
1530            encoder.token(source_reason_name(*reason));
1531        }
1532    }
1533    let evidence = &clip.evidence;
1534    encoder.f64(evidence.duration_s);
1535    encoder.bool(evidence.speed_mps.is_some());
1536    if let Some(value) = evidence.speed_mps {
1537        encoder.f64(value);
1538    }
1539    encoder.token(availability_name(evidence.speed_mps_availability));
1540    encoder.bool(evidence.loop_endpoint_mode.is_some());
1541    if let Some(value) = evidence.loop_endpoint_mode {
1542        encoder.token(value.as_str());
1543    }
1544    encoder.token(availability_name(evidence.loop_endpoint_mode_availability));
1545    encoder.bool(evidence.frame_grid.is_some());
1546    if let Some(value) = evidence.frame_grid {
1547        encoder.f64(value.fps);
1548        encoder.u64(u64::from(value.frame_intervals));
1549    }
1550    encoder.token(availability_name(evidence.frame_grid_availability));
1551    encoder.bool(evidence.looping.is_some());
1552    if let Some(value) = evidence.looping {
1553        encoder.bool(value);
1554    }
1555    for owner in [
1556        evidence.movement_owner_xz,
1557        evidence.movement_owner_y,
1558        evidence.movement_owner_yaw,
1559    ] {
1560        encoder.bool(owner.is_some());
1561        if let Some(owner) = owner {
1562            encoder.token(owner_name(owner));
1563        }
1564    }
1565}
1566
1567fn encode_payload(encoder: &mut AdviceEncoder, payload: &EngineImportAdvicePayloadV1) {
1568    match payload {
1569        EngineImportAdvicePayloadV1::UnityGeneric { document, clips } => {
1570            encoder.token("unity-generic");
1571            encode_unity_payload(encoder, document, clips);
1572        }
1573        EngineImportAdvicePayloadV1::UnityHumanoid { document, clips } => {
1574            encoder.token("unity-humanoid");
1575            encode_unity_payload(encoder, document, clips);
1576        }
1577        EngineImportAdvicePayloadV1::Unreal => encoder.token("unreal"),
1578        EngineImportAdvicePayloadV1::Godot => encoder.token("godot"),
1579    }
1580}
1581fn encode_unity_payload(
1582    encoder: &mut AdviceEncoder,
1583    document: &UnityDocumentAdviceV1,
1584    clips: &[UnityClipAdviceV1],
1585) {
1586    encoder.bool(document.convert_units);
1587    encoder.bool(document.bake_axis_conversion);
1588    encode_string_option(encoder, document.root_motion_source.as_deref());
1589    encoder.count(clips.len());
1590    for clip in clips {
1591        encoder.u64(clip.normalized_clip_index);
1592        encoder.bool(clip.lock_root_rotation);
1593        encoder.bool(clip.lock_root_height_y);
1594        encoder.bool(clip.lock_root_position_xz);
1595    }
1596}
1597
1598fn refusal_reason_name(reason: EngineImportAdviceRefusalReasonV1) -> &'static str {
1599    match reason {
1600        EngineImportAdviceRefusalReasonV1::ProfileSettingsUnmodeled => "profile_settings_unmodeled",
1601        EngineImportAdviceRefusalReasonV1::RawClipInventoryIncomplete => {
1602            "raw_clip_inventory_incomplete"
1603        }
1604        EngineImportAdviceRefusalReasonV1::ClipIdentityUnavailable => "clip_identity_unavailable",
1605        EngineImportAdviceRefusalReasonV1::ClipIdentityMismatch => "clip_identity_mismatch",
1606        EngineImportAdviceRefusalReasonV1::MeasurementUnavailable => "measurement_unavailable",
1607    }
1608}
1609fn source_reason_name(reason: EngineImportAdviceSourceUnavailableReasonV1) -> &'static str {
1610    match reason {
1611        EngineImportAdviceSourceUnavailableReasonV1::Malformed => "malformed",
1612        EngineImportAdviceSourceUnavailableReasonV1::Discarded => "discarded",
1613        EngineImportAdviceSourceUnavailableReasonV1::NormalizedAway => "normalized_away",
1614        EngineImportAdviceSourceUnavailableReasonV1::BakedAway => "baked_away",
1615        EngineImportAdviceSourceUnavailableReasonV1::LoaderUnsupported => "loader_unsupported",
1616        EngineImportAdviceSourceUnavailableReasonV1::ProjectionBudgetExceeded => {
1617            "projection_budget_exceeded"
1618        }
1619        EngineImportAdviceSourceUnavailableReasonV1::ParserUnavailable => "parser_unavailable",
1620    }
1621}
1622fn availability_name(value: MeasurementAvailability) -> &'static str {
1623    match value {
1624        MeasurementAvailability::Measured => "measured",
1625        MeasurementAvailability::NotApplicable => "not_applicable",
1626        MeasurementAvailability::Unavailable => "unavailable",
1627        _ => "unknown",
1628    }
1629}
1630fn owner_name(value: EngineImportAdviceMovementOwnerV1) -> &'static str {
1631    match value {
1632        EngineImportAdviceMovementOwnerV1::Gameplay => "gameplay",
1633        EngineImportAdviceMovementOwnerV1::Animation => "animation",
1634    }
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639    use super::*;
1640    use std::io::Cursor;
1641
1642    #[test]
1643    fn staged_reader_accepts_byte_n_and_rejects_n_plus_one_before_json_decode() {
1644        let exact = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null"), 4);
1645        assert!(matches!(
1646            exact,
1647            Err(EngineImportAdviceReadError::InvalidJson { .. })
1648        ));
1649        let over = EngineImportAdviceInput::read_from_with_limit(Cursor::new(b"null "), 4);
1650        assert!(matches!(
1651            over,
1652            Err(EngineImportAdviceReadError::ReportTooLarge { limit: 4 })
1653        ));
1654    }
1655}