Skip to main content

animsmith_core/
raw_animation_inventory.rs

1//! Bounded, index-only raw animation/channel inventory for engine predictions.
2//!
3//! This is deliberately separate from normalized [`crate::Document`] tracks.
4//! One flat candidate sequence gives deserialization a single global N+1
5//! budget while retaining source-order animation and channel identities.
6
7use crate::bounded_deserialize::{CappedSequence, deserialize_capped_sequence};
8use crate::prediction::RawSourceSetCoverageV1;
9use crate::source_facts::SourceFactsViewV1;
10use crate::{InputIdentity, SourceFormatV1};
11use serde::de::Error as _;
12use serde::{Deserialize, Deserializer, Serialize};
13
14/// Immutable raw animation/channel inventory contract identity.
15pub const RAW_ANIMATION_CHANNEL_INVENTORY_V1_ID: &str =
16    "urn:animsmith:raw-animation-channel-inventory:1";
17/// The N+1 candidate prefix retained for one track-support prediction.
18pub const RAW_ANIMATION_CHANNEL_INVENTORY_V1_MAX_CANDIDATES: usize = 4_097;
19
20fn deserialize_rows<'de, D>(deserializer: D) -> Result<Vec<RawAnimationChannelRowV1>, D::Error>
21where
22    D: Deserializer<'de>,
23{
24    let values: CappedSequence<RawAnimationChannelRowV1> = deserialize_capped_sequence(
25        deserializer,
26        RAW_ANIMATION_CHANNEL_INVENTORY_V1_MAX_CANDIDATES,
27    )?;
28    if values.overflowed {
29        return Err(D::Error::custom(
30            "raw animation/channel inventory exceeded its global candidate bound",
31        ));
32    }
33    Ok(values.values)
34}
35
36/// One row in canonical animation-then-channel candidate order.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
39pub enum RawAnimationChannelRowV1 {
40    /// One source animation and its independently observed channel coverage.
41    Animation {
42        /// Zero-based source animation-array index.
43        source_animation_index: u64,
44        /// Coverage of this animation's source channel array.
45        channel_coverage: RawSourceSetCoverageV1,
46    },
47    /// One source channel belonging to the immediately preceding animation.
48    AnimationChannel {
49        /// Zero-based source animation-array index.
50        source_animation_index: u64,
51        /// Zero-based source channel-array index.
52        source_channel_index: u64,
53    },
54}
55
56impl RawAnimationChannelRowV1 {
57    /// Source animation-array index.
58    pub const fn source_animation_index(&self) -> u64 {
59        match self {
60            Self::Animation {
61                source_animation_index,
62                ..
63            }
64            | Self::AnimationChannel {
65                source_animation_index,
66                ..
67            } => *source_animation_index,
68        }
69    }
70    /// Source channel-array index for a channel row.
71    pub const fn source_channel_index(&self) -> Option<u64> {
72        match self {
73            Self::Animation { .. } => None,
74            Self::AnimationChannel {
75                source_channel_index,
76                ..
77            } => Some(*source_channel_index),
78        }
79    }
80    /// Independent channel coverage carried by an animation row.
81    pub const fn channel_coverage(&self) -> Option<RawSourceSetCoverageV1> {
82        match self {
83            Self::Animation {
84                channel_coverage, ..
85            } => Some(*channel_coverage),
86            Self::AnimationChannel { .. } => None,
87        }
88    }
89}
90
91/// Bounded same-load raw animation/channel inventory.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct RawAnimationChannelInventoryV1 {
95    schema: String,
96    primary_input: InputIdentity,
97    source_format: SourceFormatV1,
98    animation_coverage: RawSourceSetCoverageV1,
99    source_coverage_complete: bool,
100    candidate_prefix_saturated: bool,
101    #[serde(deserialize_with = "deserialize_rows")]
102    rows: Vec<RawAnimationChannelRowV1>,
103}
104
105impl RawAnimationChannelInventoryV1 {
106    /// Capture the canonical N+1 subject prefix and aggregate source coverage.
107    pub fn from_source(facts: SourceFactsViewV1<'_>) -> Self {
108        let mut rows = Vec::new();
109        let mut candidate_prefix_saturated = false;
110        let mut source_coverage_complete =
111            facts.clips().coverage().state() == crate::SourceSetCoverageStateV1::Complete;
112        'animations: for animation in facts.clips().rows() {
113            if !source_coverage_complete {
114                break;
115            }
116            let channel_coverage: RawSourceSetCoverageV1 = animation.channels().coverage().into();
117            source_coverage_complete &=
118                channel_coverage.state() == crate::RawSourceSetCoverageStateV1::Complete;
119            rows.push(RawAnimationChannelRowV1::Animation {
120                source_animation_index: animation.source_clip_index() as u64,
121                channel_coverage,
122            });
123            if rows.len() == RAW_ANIMATION_CHANNEL_INVENTORY_V1_MAX_CANDIDATES {
124                candidate_prefix_saturated = true;
125                break;
126            }
127            if !source_coverage_complete {
128                break;
129            }
130            for channel in animation.channels().rows() {
131                rows.push(RawAnimationChannelRowV1::AnimationChannel {
132                    source_animation_index: animation.source_clip_index() as u64,
133                    source_channel_index: channel.source_channel_index() as u64,
134                });
135                if rows.len() == RAW_ANIMATION_CHANNEL_INVENTORY_V1_MAX_CANDIDATES {
136                    candidate_prefix_saturated = true;
137                    break 'animations;
138                }
139            }
140        }
141        Self {
142            schema: RAW_ANIMATION_CHANNEL_INVENTORY_V1_ID.into(),
143            primary_input: facts.primary_identity().clone(),
144            source_format: facts.format(),
145            animation_coverage: facts.clips().coverage().into(),
146            source_coverage_complete,
147            candidate_prefix_saturated,
148            rows,
149        }
150    }
151
152    /// Validate coverage states, bounded work, and contiguous source ordering.
153    pub fn validate(&self) -> Result<(), &'static str> {
154        if self.schema != RAW_ANIMATION_CHANNEL_INVENTORY_V1_ID
155            || self.rows.len() > RAW_ANIMATION_CHANNEL_INVENTORY_V1_MAX_CANDIDATES
156            || self.candidate_prefix_saturated
157                != (self.rows.len() == RAW_ANIMATION_CHANNEL_INVENTORY_V1_MAX_CANDIDATES)
158            || !valid_coverage(self.animation_coverage)
159        {
160            return Err("invalid raw animation/channel inventory header or bound");
161        }
162        let mut expected_animation = 0u64;
163        let mut current_animation = None;
164        let mut expected_channel = 0u64;
165        for row in &self.rows {
166            match row {
167                RawAnimationChannelRowV1::Animation {
168                    source_animation_index,
169                    channel_coverage,
170                } => {
171                    if *source_animation_index != expected_animation
172                        || !valid_coverage(*channel_coverage)
173                    {
174                        return Err("raw animation rows are not contiguous or valid");
175                    }
176                    expected_animation = expected_animation.saturating_add(1);
177                    current_animation = Some(*source_animation_index);
178                    expected_channel = 0;
179                }
180                RawAnimationChannelRowV1::AnimationChannel {
181                    source_animation_index,
182                    source_channel_index,
183                } => {
184                    if current_animation != Some(*source_animation_index)
185                        || *source_channel_index != expected_channel
186                    {
187                        return Err("raw channel rows are not contiguous or attached");
188                    }
189                    expected_channel = expected_channel.saturating_add(1);
190                }
191            }
192        }
193        let retained_coverage_complete = self.animation_coverage.state()
194            == crate::RawSourceSetCoverageStateV1::Complete
195            && self.rows.iter().all(|row| {
196                row.channel_coverage().is_none_or(|coverage| {
197                    coverage.state() == crate::RawSourceSetCoverageStateV1::Complete
198                })
199            });
200        if self.source_coverage_complete != retained_coverage_complete {
201            return Err("raw animation/channel aggregate coverage is contradictory");
202        }
203        Ok(())
204    }
205
206    /// Exact primary input identity.
207    pub const fn primary_input(&self) -> &InputIdentity {
208        &self.primary_input
209    }
210    /// Exact source format.
211    pub const fn source_format(&self) -> SourceFormatV1 {
212        self.source_format
213    }
214    /// Animation inventory coverage.
215    pub const fn animation_coverage(&self) -> RawSourceSetCoverageV1 {
216        self.animation_coverage
217    }
218    /// Number of retained animation/channel candidates.
219    pub fn candidate_count(&self) -> u64 {
220        self.rows.len() as u64
221    }
222    /// Whether source demand exceeded the retained N+1 prefix.
223    pub const fn candidate_overflow(&self) -> bool {
224        self.candidate_prefix_saturated
225    }
226    /// Canonical flat candidate prefix.
227    pub fn rows(&self) -> &[RawAnimationChannelRowV1] {
228        &self.rows
229    }
230    /// Whether complete coverage proves the source has no animation subjects.
231    pub fn is_complete_empty(&self) -> bool {
232        self.source_coverage_complete && self.rows.is_empty()
233    }
234    /// Whether every source animation/channel inventory was observed completely.
235    pub const fn source_coverage_complete(&self) -> bool {
236        self.source_coverage_complete
237    }
238}
239
240fn valid_coverage(coverage: RawSourceSetCoverageV1) -> bool {
241    matches!(
242        (coverage.state(), coverage.reason()),
243        (crate::RawSourceSetCoverageStateV1::Complete, None)
244            | (crate::RawSourceSetCoverageStateV1::Partial, Some(_))
245            | (crate::RawSourceSetCoverageStateV1::Unavailable, Some(_))
246    )
247}