Skip to main content

animsmith_core/
source_facts.rs

1//! Bounded, format-neutral observations retained from a source loader.
2//!
3//! This module binds immutable raw-source observations to the normalized
4//! [`Document`] produced from the same primary bytes.
5
6use crate::{
7    DependencyClosureError, DependencyClosureV1, Document, ExactSourceTimingContractError,
8    ExactSourceTimingV1, InputIdentity, RawGltfAddressabilityInventoryV1,
9    RawSceneAttachmentCoverageV1, RawSceneAttachmentInventoryV1, RawSourceSkeletonEvidenceV1,
10    RawTransformPathInventoryV1, SourceSkeletonAssets, SourceSkeletonCoverage,
11};
12use serde::Serialize;
13use std::fmt;
14
15/// Semantic identity of the in-memory raw-source vocabulary.
16pub const RAW_SOURCE_FACTS_V1_ID: &str = "urn:animsmith:raw-source-facts:1";
17/// Maximum enumerable clip/channel/construct/resource rows retained by one V1 projection.
18pub const RAW_SOURCE_V1_MAX_OBSERVATIONS: usize = 65_536;
19/// Maximum source clips/takes retained by one V1 projection.
20pub const RAW_SOURCE_V1_MAX_CLIPS: usize = 4_096;
21/// Maximum external-resource declarations retained by one V1 projection.
22pub const RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES: usize = 4_096;
23/// Maximum UTF-8 bytes retained in one source text or locator.
24pub const RAW_SOURCE_V1_MAX_TEXT_BYTES: usize = 4_096;
25/// Maximum UTF-8 source-text bytes retained by one V1 projection.
26pub const RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES: usize = 8 * 1024 * 1024;
27/// Maximum source traversal depth inspected by a V1 projection.
28pub const RAW_SOURCE_V1_MAX_TRAVERSAL_DEPTH: usize = 128;
29
30/// Exact input container recognized by a format loader.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum SourceFormatV1 {
33    /// JSON `.gltf` container.
34    GltfJson,
35    /// Binary `.glb` container.
36    Glb,
37    /// Autodesk FBX container parsed through `ufbx`.
38    Fbx,
39}
40
41/// Stable reason why a source observation or set is incomplete.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
43pub enum SourceUnavailableReasonV1 {
44    /// The source declaration is malformed.
45    Malformed,
46    /// The AnimSmith loader discarded the source value.
47    Discarded,
48    /// Coordinate or transform normalization removed the original form.
49    NormalizedAway,
50    /// Animation baking removed the original form.
51    BakedAway,
52    /// The AnimSmith loader does not model this source domain.
53    LoaderUnsupported,
54    /// The deterministic V1 projection budget was exhausted.
55    ProjectionBudgetExceeded,
56    /// The source parser did not make this evidence available.
57    ParserUnavailable,
58}
59
60/// Whether a source-fact row set is exhaustive.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SourceSetCoverageStateV1 {
63    /// Every row in the source domain is represented.
64    Complete,
65    /// Retained rows are authoritative positive-presence evidence, but the set is truncated.
66    Partial,
67    /// No exhaustive row projection is available.
68    Unavailable,
69}
70
71/// Coverage and reason for one independently enumerable source-fact domain.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct SourceSetCoverageV1 {
74    state: SourceSetCoverageStateV1,
75    reason: Option<SourceUnavailableReasonV1>,
76}
77
78impl SourceSetCoverageV1 {
79    /// Complete coverage; an empty set proves absence.
80    pub const fn complete() -> Self {
81        Self {
82            state: SourceSetCoverageStateV1::Complete,
83            reason: None,
84        }
85    }
86
87    /// Partial coverage; retained rows prove presence but omissions prove nothing.
88    pub const fn partial(reason: SourceUnavailableReasonV1) -> Self {
89        Self {
90            state: SourceSetCoverageStateV1::Partial,
91            reason: Some(reason),
92        }
93    }
94
95    /// Unavailable coverage for a source domain.
96    pub const fn unavailable(reason: SourceUnavailableReasonV1) -> Self {
97        Self {
98            state: SourceSetCoverageStateV1::Unavailable,
99            reason: Some(reason),
100        }
101    }
102
103    /// Coverage state.
104    pub const fn state(self) -> SourceSetCoverageStateV1 {
105        self.state
106    }
107
108    /// Stable incompleteness reason, absent only for complete coverage.
109    pub const fn reason(self) -> Option<SourceUnavailableReasonV1> {
110        self.reason
111    }
112
113    /// Whether an empty row set proves source absence.
114    pub const fn proves_absence(self) -> bool {
115        matches!(self.state, SourceSetCoverageStateV1::Complete)
116    }
117}
118
119/// What the AnimSmith loader did with an observed source declaration.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
121pub enum SourceLoaderDispositionV1 {
122    /// Retained without semantic reinterpretation.
123    Preserved,
124    /// Converted into AnimSmith's normalized coordinate/model domain.
125    Normalized,
126    /// Evaluated into baked samples.
127    Baked,
128    /// Deliberately omitted from the normalized document.
129    Discarded,
130    /// Recognized but unsupported by the AnimSmith loader.
131    Unsupported,
132    /// The loader cannot classify its treatment.
133    Unknown,
134    /// The source domain does not apply to this format.
135    NotApplicable,
136}
137
138/// How a source fact was established.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
140pub enum SourceProvenanceKindV1 {
141    /// Normative interchange-format semantics rather than a file member.
142    FormatDefined,
143    /// An exact source declaration retained across the loader boundary.
144    SourceDeclared,
145    /// A parser-effective projection of source declarations.
146    ParserProjected,
147    /// Derived from exact authored source declarations.
148    DerivedFromSource,
149}
150
151/// Bounded source text safe for the raw-facts surface.
152#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
153pub struct SourceTextV1(String);
154
155impl SourceTextV1 {
156    /// Retain bounded UTF-8 source text.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`SourceFactsError::TextTooLong`] when the text exceeds the V1 limit.
161    pub fn new(value: impl AsRef<str>) -> Result<Self, SourceFactsError> {
162        let value = value.as_ref();
163        if value.len() > RAW_SOURCE_V1_MAX_TEXT_BYTES {
164            return Err(SourceFactsError::TextTooLong {
165                bytes: value.len(),
166                limit: RAW_SOURCE_V1_MAX_TEXT_BYTES,
167            });
168        }
169        Ok(Self(value.to_owned()))
170    }
171
172    /// Retained source spelling.
173    pub fn as_str(&self) -> &str {
174        &self.0
175    }
176
177    fn retained_bytes(&self) -> usize {
178        self.0.len()
179    }
180}
181
182impl fmt::Debug for SourceTextV1 {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter
185            .debug_tuple("SourceTextV1")
186            .field(&self.0)
187            .finish()
188    }
189}
190
191/// Validated bounded source-internal locator used only for V1 provenance.
192#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
193pub struct SourceLogicalLocatorV1 {
194    text: SourceTextV1,
195}
196
197impl SourceLogicalLocatorV1 {
198    /// Validate a generated glTF JSON Pointer used by this V1 fact model.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`SourceFactsError::InvalidLogicalLocator`] outside the
203    /// structural vocabulary and [`SourceFactsError::TextTooLong`] when oversized.
204    pub fn gltf_json_pointer(value: impl AsRef<str>) -> Result<Self, SourceFactsError> {
205        let value = value.as_ref();
206        let mut segments = value
207            .strip_prefix('/')
208            .into_iter()
209            .flat_map(|value| value.split('/'));
210        let valid_root = matches!(
211            segments.next(),
212            Some("animations" | "buffers" | "images" | "extensionsUsed" | "extensionsRequired")
213        );
214        if !valid_root || !segments.all(valid_logical_segment) {
215            return Err(SourceFactsError::InvalidLogicalLocator);
216        }
217        Ok(Self {
218            text: SourceTextV1::new(value)?,
219        })
220    }
221
222    /// Validate a generated parser-domain locator under the `fbx:` namespace.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`SourceFactsError::InvalidLogicalLocator`] for host/resource
227    /// paths, controls, or malformed segments, and
228    /// [`SourceFactsError::TextTooLong`] when oversized.
229    pub fn fbx_parser_path(value: impl AsRef<str>) -> Result<Self, SourceFactsError> {
230        let value = value.as_ref();
231        let Some(path) = value.strip_prefix("fbx:") else {
232            return Err(SourceFactsError::InvalidLogicalLocator);
233        };
234        if path.is_empty()
235            || path
236                .split('/')
237                .any(|segment| !valid_logical_segment(segment))
238        {
239            return Err(SourceFactsError::InvalidLogicalLocator);
240        }
241        Ok(Self {
242            text: SourceTextV1::new(value)?,
243        })
244    }
245
246    /// Retained generated logical spelling.
247    pub fn as_str(&self) -> &str {
248        self.text.as_str()
249    }
250
251    fn retained_bytes(&self) -> usize {
252        self.text.retained_bytes()
253    }
254}
255
256fn valid_logical_segment(segment: &str) -> bool {
257    !segment.is_empty()
258        && !matches!(segment, "." | "..")
259        && segment.bytes().all(|value| {
260            value.is_ascii_alphanumeric() || matches!(value, b'_' | b'-' | b'.' | b'*')
261        })
262}
263
264/// Provenance for one source observation.
265#[derive(Clone, PartialEq, Eq)]
266pub struct SourceProvenanceV1 {
267    kind: SourceProvenanceKindV1,
268    locator: Option<SourceLogicalLocatorV1>,
269}
270
271impl fmt::Debug for SourceProvenanceV1 {
272    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273        formatter
274            .debug_struct("SourceProvenanceV1")
275            .field("kind", &self.kind)
276            .field("locator_retained", &self.locator.is_some())
277            .finish()
278    }
279}
280
281impl SourceProvenanceV1 {
282    /// Format-defined evidence without a file-member locator.
283    pub const fn format_defined() -> Self {
284        Self {
285            kind: SourceProvenanceKindV1::FormatDefined,
286            locator: None,
287        }
288    }
289
290    /// Exact source-declaration evidence at a validated logical locator.
291    pub fn source_declared(locator: SourceLogicalLocatorV1) -> Self {
292        Self {
293            kind: SourceProvenanceKindV1::SourceDeclared,
294            locator: Some(locator),
295        }
296    }
297
298    /// Parser-effective evidence at a validated logical locator.
299    pub fn parser_projected(locator: SourceLogicalLocatorV1) -> Self {
300        Self {
301            kind: SourceProvenanceKindV1::ParserProjected,
302            locator: Some(locator),
303        }
304    }
305
306    /// Evidence derived from an exact declaration at a validated logical locator.
307    pub fn derived_from_source(locator: SourceLogicalLocatorV1) -> Self {
308        Self {
309            kind: SourceProvenanceKindV1::DerivedFromSource,
310            locator: Some(locator),
311        }
312    }
313
314    /// Provenance kind.
315    pub const fn kind(&self) -> SourceProvenanceKindV1 {
316        self.kind
317    }
318
319    /// Bounded source-internal logical locator, when one exists.
320    pub fn locator(&self) -> Option<&SourceLogicalLocatorV1> {
321        self.locator.as_ref()
322    }
323
324    fn retained_bytes(&self) -> usize {
325        self.locator
326            .as_ref()
327            .map_or(0, SourceLogicalLocatorV1::retained_bytes)
328    }
329}
330
331/// Availability of one scalar source observation.
332#[derive(Debug, Clone, PartialEq)]
333pub enum SourceObservationStateV1<T> {
334    /// The value was observed at the documented provenance boundary.
335    Observed(T),
336    /// Complete evidence proves that this format/source has no value.
337    ProvenAbsent,
338    /// The value cannot be established.
339    Unavailable(SourceUnavailableReasonV1),
340}
341
342/// One value with orthogonal availability, provenance, and loader treatment.
343#[derive(Debug, Clone, PartialEq)]
344pub struct SourceObservationV1<T> {
345    state: SourceObservationStateV1<T>,
346    disposition: SourceLoaderDispositionV1,
347    provenance: Option<SourceProvenanceV1>,
348}
349
350impl<T> SourceObservationV1<T> {
351    /// Retain one observed value with explicit provenance and loader treatment.
352    pub fn observed(
353        value: T,
354        provenance: SourceProvenanceV1,
355        disposition: SourceLoaderDispositionV1,
356    ) -> Self {
357        Self {
358            state: SourceObservationStateV1::Observed(value),
359            disposition,
360            provenance: Some(provenance),
361        }
362    }
363
364    /// Prove that a complete source domain has no such value.
365    pub fn proven_absent(provenance: SourceProvenanceV1) -> Self {
366        Self {
367            state: SourceObservationStateV1::ProvenAbsent,
368            disposition: SourceLoaderDispositionV1::NotApplicable,
369            provenance: Some(provenance),
370        }
371    }
372
373    /// Record unavailable evidence without substituting a normalized value.
374    pub fn unavailable(
375        reason: SourceUnavailableReasonV1,
376        provenance: Option<SourceProvenanceV1>,
377        disposition: SourceLoaderDispositionV1,
378    ) -> Self {
379        Self {
380            state: SourceObservationStateV1::Unavailable(reason),
381            disposition,
382            provenance,
383        }
384    }
385
386    /// Availability/value state.
387    pub const fn state(&self) -> &SourceObservationStateV1<T> {
388        &self.state
389    }
390
391    /// AnimSmith-loader disposition, independent of target-engine policy.
392    pub const fn disposition(&self) -> SourceLoaderDispositionV1 {
393        self.disposition
394    }
395
396    /// Evidence provenance, when the loader retained it.
397    pub fn provenance(&self) -> Option<&SourceProvenanceV1> {
398        self.provenance.as_ref()
399    }
400
401    fn retained_bytes(&self) -> usize {
402        self.provenance
403            .as_ref()
404            .map_or(0, SourceProvenanceV1::retained_bytes)
405    }
406}
407
408impl SourceObservationV1<SourceTextV1> {
409    fn retained_text_bytes(&self) -> usize {
410        let value_bytes = match &self.state {
411            SourceObservationStateV1::Observed(value) => value.retained_bytes(),
412            SourceObservationStateV1::ProvenAbsent | SourceObservationStateV1::Unavailable(_) => 0,
413        };
414        value_bytes.saturating_add(self.retained_bytes())
415    }
416}
417
418/// Coverage-qualified rows from one source domain.
419#[derive(Debug, Clone, PartialEq)]
420pub struct SourceFactSetV1<T> {
421    coverage: SourceSetCoverageV1,
422    rows: Vec<T>,
423}
424
425impl<T> SourceFactSetV1<T> {
426    /// Complete source-order rows; empty proves absence.
427    pub fn complete(rows: Vec<T>) -> Self {
428        Self {
429            coverage: SourceSetCoverageV1::complete(),
430            rows,
431        }
432    }
433
434    /// Deterministic positive-presence prefix from a truncated domain.
435    pub fn partial(rows: Vec<T>, reason: SourceUnavailableReasonV1) -> Self {
436        Self {
437            coverage: SourceSetCoverageV1::partial(reason),
438            rows,
439        }
440    }
441
442    /// No rows are available for the domain.
443    pub fn unavailable(reason: SourceUnavailableReasonV1) -> Self {
444        Self {
445            coverage: SourceSetCoverageV1::unavailable(reason),
446            rows: Vec::new(),
447        }
448    }
449
450    /// Coverage for this independently enumerable domain.
451    pub const fn coverage(&self) -> SourceSetCoverageV1 {
452        self.coverage
453    }
454
455    /// Retained deterministic rows.
456    pub fn rows(&self) -> &[T] {
457        &self.rows
458    }
459
460    /// Whether this complete empty set proves source absence.
461    pub fn proves_absence(&self) -> bool {
462        self.rows.is_empty() && self.coverage.proves_absence()
463    }
464
465    fn mark_partial(&mut self, reason: SourceUnavailableReasonV1) {
466        self.coverage = SourceSetCoverageV1::partial(reason);
467    }
468}
469
470/// Signed coordinate axis used by a source basis.
471#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
472pub enum SourceAxisV1 {
473    /// Positive X.
474    PositiveX,
475    /// Negative X.
476    NegativeX,
477    /// Positive Y.
478    PositiveY,
479    /// Negative Y.
480    NegativeY,
481    /// Positive Z.
482    PositiveZ,
483    /// Negative Z.
484    NegativeZ,
485}
486
487impl SourceAxisV1 {
488    const fn unsigned(self) -> u8 {
489        match self {
490            Self::PositiveX | Self::NegativeX => 0,
491            Self::PositiveY | Self::NegativeY => 1,
492            Self::PositiveZ | Self::NegativeZ => 2,
493        }
494    }
495
496    const fn vector(self) -> [i8; 3] {
497        match self {
498            Self::PositiveX => [1, 0, 0],
499            Self::NegativeX => [-1, 0, 0],
500            Self::PositiveY => [0, 1, 0],
501            Self::NegativeY => [0, -1, 0],
502            Self::PositiveZ => [0, 0, 1],
503            Self::NegativeZ => [0, 0, -1],
504        }
505    }
506}
507
508/// Handedness derived from a complete signed right/up/forward basis.
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum SourceHandednessV1 {
511    /// Right-handed signed basis.
512    Right,
513    /// Left-handed signed basis.
514    Left,
515}
516
517/// Validated signed semantic right/up/forward source basis.
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub struct SourceCoordinateBasisV1 {
520    right: SourceAxisV1,
521    up: SourceAxisV1,
522    forward: SourceAxisV1,
523}
524
525impl SourceCoordinateBasisV1 {
526    /// Construct one orthogonal signed semantic basis.
527    ///
528    /// # Errors
529    ///
530    /// Returns [`SourceFactsError::DuplicateBasisAxis`] when two semantic
531    /// directions use the same unsigned axis.
532    pub fn new(
533        right: SourceAxisV1,
534        up: SourceAxisV1,
535        forward: SourceAxisV1,
536    ) -> Result<Self, SourceFactsError> {
537        if right.unsigned() == up.unsigned()
538            || right.unsigned() == forward.unsigned()
539            || up.unsigned() == forward.unsigned()
540        {
541            return Err(SourceFactsError::DuplicateBasisAxis);
542        }
543        Ok(Self { right, up, forward })
544    }
545
546    /// Semantic right direction.
547    pub const fn right(self) -> SourceAxisV1 {
548        self.right
549    }
550
551    /// Semantic up direction.
552    pub const fn up(self) -> SourceAxisV1 {
553        self.up
554    }
555
556    /// Semantic forward direction.
557    pub const fn forward(self) -> SourceAxisV1 {
558        self.forward
559    }
560
561    /// Handedness derived from the determinant of right/up/forward.
562    pub fn handedness(self) -> SourceHandednessV1 {
563        let [rx, ry, rz] = self.right.vector();
564        let [ux, uy, uz] = self.up.vector();
565        let [fx, fy, fz] = self.forward.vector();
566        let determinant = i16::from(rx)
567            * (i16::from(uy) * i16::from(fz) - i16::from(uz) * i16::from(fy))
568            - i16::from(ry) * (i16::from(ux) * i16::from(fz) - i16::from(uz) * i16::from(fx))
569            + i16::from(rz) * (i16::from(ux) * i16::from(fy) - i16::from(uy) * i16::from(fx));
570        if determinant > 0 {
571            SourceHandednessV1::Right
572        } else {
573            SourceHandednessV1::Left
574        }
575    }
576}
577
578/// Validated linear unit expressed as metres per source unit.
579#[derive(Debug, Clone, Copy, PartialEq)]
580pub struct SourceLinearUnitV1(f64);
581
582impl SourceLinearUnitV1 {
583    /// Construct a finite positive source linear unit.
584    ///
585    /// # Errors
586    ///
587    /// Returns [`SourceFactsError::InvalidLinearUnit`] for zero, negative, NaN, or infinity.
588    pub fn new(meters_per_source_unit: f64) -> Result<Self, SourceFactsError> {
589        if !meters_per_source_unit.is_finite() || meters_per_source_unit <= 0.0 {
590            return Err(SourceFactsError::InvalidLinearUnit);
591        }
592        Ok(Self(meters_per_source_unit))
593    }
594
595    /// Metres represented by one source linear unit.
596    pub const fn meters_per_source_unit(self) -> f64 {
597        self.0
598    }
599}
600
601/// Validated finite positive source frame rate.
602#[derive(Debug, Clone, Copy, PartialEq)]
603pub struct SourceFramesPerSecondV1(f64);
604
605impl SourceFramesPerSecondV1 {
606    /// Construct a finite positive frame rate.
607    ///
608    /// # Errors
609    ///
610    /// Returns [`SourceFactsError::InvalidFramesPerSecond`] for zero, negative, NaN, or infinity.
611    pub fn new(value: f64) -> Result<Self, SourceFactsError> {
612        if !value.is_finite() || value <= 0.0 {
613            return Err(SourceFactsError::InvalidFramesPerSecond);
614        }
615        Ok(Self(value))
616    }
617
618    /// Frames per second.
619    pub const fn get(self) -> f64 {
620        self.0
621    }
622}
623
624/// Finite inclusive source-time range in seconds.
625#[derive(Debug, Clone, Copy, PartialEq)]
626pub struct SourceTimeRangeV1 {
627    begin_s: f64,
628    end_s: f64,
629}
630
631impl SourceTimeRangeV1 {
632    /// Construct a finite ordered source-time range. Zero duration is valid.
633    ///
634    /// # Errors
635    ///
636    /// Returns [`SourceFactsError::InvalidTimeRange`] for non-finite or reversed endpoints.
637    pub fn new(begin_s: f64, end_s: f64) -> Result<Self, SourceFactsError> {
638        if !begin_s.is_finite() || !end_s.is_finite() || begin_s > end_s {
639            return Err(SourceFactsError::InvalidTimeRange);
640        }
641        Ok(Self { begin_s, end_s })
642    }
643
644    /// Beginning of the source range in seconds.
645    pub const fn begin_s(self) -> f64 {
646        self.begin_s
647    }
648
649    /// End of the source range in seconds.
650    pub const fn end_s(self) -> f64 {
651        self.end_s
652    }
653}
654
655/// Format-neutral animation property present at the source boundary.
656#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
657pub enum SourceChannelPropertyV1 {
658    /// Local translation.
659    Translation,
660    /// Local rotation.
661    Rotation,
662    /// Local scale.
663    Scale,
664    /// Morph-target weights.
665    Weights,
666    /// Another bounded source property.
667    Other,
668}
669
670/// Source interpolation retained by a loader.
671#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
672pub enum SourceInterpolationV1 {
673    /// Step/constant interpolation.
674    Step,
675    /// Linear interpolation.
676    Linear,
677    /// Cubic-spline interpolation.
678    CubicSpline,
679    /// Another source interpolation spelling.
680    Other,
681}
682
683/// Component curves present for a source property.
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685pub struct SourceComponentMaskV1 {
686    x: bool,
687    y: bool,
688    z: bool,
689}
690
691impl SourceComponentMaskV1 {
692    /// Construct an explicit source component mask.
693    pub const fn new(x: bool, y: bool, z: bool) -> Self {
694        Self { x, y, z }
695    }
696
697    /// Whether the X/component-0 curve is present.
698    pub const fn x(self) -> bool {
699        self.x
700    }
701
702    /// Whether the Y/component-1 curve is present.
703    pub const fn y(self) -> bool {
704        self.y
705    }
706
707    /// Whether the Z/component-2 curve is present.
708    pub const fn z(self) -> bool {
709        self.z
710    }
711}
712
713/// Source target identity kind.
714#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
715pub enum SourceTargetKindV1 {
716    /// Source node-array identity.
717    Node,
718    /// Parser-stable non-node element identity.
719    Element,
720    /// Another source target domain.
721    Other,
722}
723
724/// Stable target identity retained beside one channel/property declaration.
725#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
726pub struct SourceTargetV1 {
727    kind: SourceTargetKindV1,
728    index: u64,
729}
730
731impl SourceTargetV1 {
732    /// Construct a stable source target identity.
733    pub const fn new(kind: SourceTargetKindV1, index: u64) -> Self {
734        Self { kind, index }
735    }
736
737    /// Target identity kind.
738    pub const fn kind(self) -> SourceTargetKindV1 {
739        self.kind
740    }
741
742    /// Stable index in the documented source/parser domain.
743    pub const fn index(self) -> u64 {
744        self.index
745    }
746}
747
748/// One raw animation channel/property declaration.
749#[derive(Debug, Clone, PartialEq)]
750pub struct SourceChannelFactV1 {
751    source_channel_index: usize,
752    source_layer_index: Option<usize>,
753    target: SourceTargetV1,
754    property: SourceChannelPropertyV1,
755    property_name: Option<SourceTextV1>,
756    components: SourceComponentMaskV1,
757    interpolation: SourceObservationV1<SourceInterpolationV1>,
758    input_accessor_index: Option<usize>,
759    output_accessor_index: Option<usize>,
760    disposition: SourceLoaderDispositionV1,
761    provenance: SourceProvenanceV1,
762}
763
764impl SourceChannelFactV1 {
765    /// Construct one stable source channel/property row.
766    pub fn new(
767        source_channel_index: usize,
768        target: SourceTargetV1,
769        property: SourceChannelPropertyV1,
770        components: SourceComponentMaskV1,
771        interpolation: SourceObservationV1<SourceInterpolationV1>,
772        disposition: SourceLoaderDispositionV1,
773        provenance: SourceProvenanceV1,
774    ) -> Self {
775        Self {
776            source_channel_index,
777            source_layer_index: None,
778            target,
779            property,
780            property_name: None,
781            components,
782            interpolation,
783            input_accessor_index: None,
784            output_accessor_index: None,
785            disposition,
786            provenance,
787        }
788    }
789
790    /// Attach the source animation-layer index used by layered formats.
791    pub fn with_source_layer_index(mut self, index: usize) -> Self {
792        self.source_layer_index = Some(index);
793        self
794    }
795
796    /// Attach a bounded source property spelling, primarily for [`SourceChannelPropertyV1::Other`].
797    pub fn with_property_name(mut self, name: SourceTextV1) -> Self {
798        self.property_name = Some(name);
799        self
800    }
801
802    /// Attach exact glTF input/output accessor identities.
803    pub fn with_accessors(mut self, input: usize, output: usize) -> Self {
804        self.input_accessor_index = Some(input);
805        self.output_accessor_index = Some(output);
806        self
807    }
808
809    /// Stable channel/property index inside its source clip/layer projection.
810    pub const fn source_channel_index(&self) -> usize {
811        self.source_channel_index
812    }
813
814    /// Source animation-layer index, when the format has layers.
815    pub const fn source_layer_index(&self) -> Option<usize> {
816        self.source_layer_index
817    }
818
819    /// Stable source target identity.
820    pub const fn target(&self) -> SourceTargetV1 {
821        self.target
822    }
823
824    /// Format-neutral property kind.
825    pub const fn property(&self) -> SourceChannelPropertyV1 {
826        self.property
827    }
828
829    /// Source property spelling for an `Other` property, when retained.
830    pub fn property_name(&self) -> Option<&SourceTextV1> {
831        self.property_name.as_ref()
832    }
833
834    /// Present component curves.
835    pub const fn components(&self) -> SourceComponentMaskV1 {
836        self.components
837    }
838
839    /// Source interpolation evidence.
840    pub const fn interpolation(&self) -> &SourceObservationV1<SourceInterpolationV1> {
841        &self.interpolation
842    }
843
844    /// Exact glTF input accessor index, when applicable.
845    pub const fn input_accessor_index(&self) -> Option<usize> {
846        self.input_accessor_index
847    }
848
849    /// Exact glTF output accessor index, when applicable.
850    pub const fn output_accessor_index(&self) -> Option<usize> {
851        self.output_accessor_index
852    }
853
854    /// AnimSmith-loader treatment of this channel/property.
855    pub const fn disposition(&self) -> SourceLoaderDispositionV1 {
856        self.disposition
857    }
858
859    /// Source provenance for this declaration.
860    pub const fn provenance(&self) -> &SourceProvenanceV1 {
861        &self.provenance
862    }
863
864    fn retained_bytes(&self) -> usize {
865        self.property_name
866            .as_ref()
867            .map_or(0, SourceTextV1::retained_bytes)
868            .saturating_add(self.interpolation.retained_bytes())
869            .saturating_add(self.provenance.retained_bytes())
870    }
871}
872
873/// One source animation/stack and its independently covered channel rows.
874#[derive(Debug, Clone, PartialEq)]
875pub struct SourceClipFactV1 {
876    source_clip_index: usize,
877    source_name: SourceObservationV1<SourceTextV1>,
878    normalized_clip_index: SourceObservationV1<usize>,
879    source_range: SourceObservationV1<SourceTimeRangeV1>,
880    sampler_range: SourceObservationV1<SourceTimeRangeV1>,
881    channels: SourceFactSetV1<SourceChannelFactV1>,
882}
883
884impl SourceClipFactV1 {
885    /// Construct one source clip/take fact row.
886    pub fn new(
887        source_clip_index: usize,
888        source_name: SourceObservationV1<SourceTextV1>,
889        normalized_clip_index: SourceObservationV1<usize>,
890        source_range: SourceObservationV1<SourceTimeRangeV1>,
891        sampler_range: SourceObservationV1<SourceTimeRangeV1>,
892        channels: SourceFactSetV1<SourceChannelFactV1>,
893    ) -> Self {
894        Self {
895            source_clip_index,
896            source_name,
897            normalized_clip_index,
898            source_range,
899            sampler_range,
900            channels,
901        }
902    }
903
904    /// Stable source animation/stack index.
905    pub const fn source_clip_index(&self) -> usize {
906        self.source_clip_index
907    }
908
909    /// Authored/parser source name, distinct from a normalized synthetic name.
910    pub const fn source_name(&self) -> &SourceObservationV1<SourceTextV1> {
911        &self.source_name
912    }
913
914    /// Mapping into the normalized [`Document::clips`] array.
915    pub const fn normalized_clip_index(&self) -> &SourceObservationV1<usize> {
916        &self.normalized_clip_index
917    }
918
919    /// Parser-resolved source stack range, when this source format has one.
920    pub const fn source_range(&self) -> &SourceObservationV1<SourceTimeRangeV1> {
921        &self.source_range
922    }
923
924    /// Range derived from exact authored sampler inputs, when applicable.
925    pub const fn sampler_range(&self) -> &SourceObservationV1<SourceTimeRangeV1> {
926        &self.sampler_range
927    }
928
929    /// Raw channel/property rows and their independent coverage.
930    pub const fn channels(&self) -> &SourceFactSetV1<SourceChannelFactV1> {
931        &self.channels
932    }
933
934    fn retained_row_count(&self) -> usize {
935        1usize.saturating_add(self.channels.rows.len())
936    }
937
938    fn retained_bytes(&self) -> usize {
939        self.retained_non_channel_bytes().saturating_add(
940            self.channels
941                .rows
942                .iter()
943                .map(SourceChannelFactV1::retained_bytes)
944                .fold(0usize, usize::saturating_add),
945        )
946    }
947
948    fn retained_non_channel_bytes(&self) -> usize {
949        self.source_name
950            .retained_text_bytes()
951            .saturating_add(self.normalized_clip_index.retained_bytes())
952            .saturating_add(self.source_range.retained_bytes())
953            .saturating_add(self.sampler_range.retained_bytes())
954    }
955
956    fn truncate_channels(&mut self, retained: usize) {
957        if self.channels.rows.len() > retained {
958            self.channels.rows.truncate(retained);
959            self.channels
960                .mark_partial(SourceUnavailableReasonV1::ProjectionBudgetExceeded);
961        }
962    }
963
964    fn truncate_channels_to_text(&mut self, available_bytes: usize) -> bool {
965        let fixed_bytes = self.retained_non_channel_bytes();
966        if fixed_bytes > available_bytes {
967            return false;
968        }
969        let mut retained_bytes = fixed_bytes;
970        let retained_channels = self
971            .channels
972            .rows
973            .iter()
974            .take_while(|channel| {
975                let next = retained_bytes.saturating_add(channel.retained_bytes());
976                if next > available_bytes {
977                    false
978                } else {
979                    retained_bytes = next;
980                    true
981                }
982            })
983            .count();
984        self.truncate_channels(retained_channels);
985        true
986    }
987}
988
989/// General source construct kind retained outside normalized animation tracks.
990#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
991pub enum SourceConstructKindV1 {
992    /// glTF extension declaration.
993    Extension,
994    /// Parser-projected user/custom property domain.
995    CustomProperty,
996    /// Parser-projected unmodeled source element/domain.
997    UnknownElement,
998}
999
1000/// One extension/custom/unmodeled source construct declaration.
1001#[derive(Debug, Clone, PartialEq, Eq)]
1002pub struct SourceConstructFactV1 {
1003    source_order_index: usize,
1004    kind: SourceConstructKindV1,
1005    name: SourceTextV1,
1006    required: bool,
1007    count: u64,
1008    disposition: SourceLoaderDispositionV1,
1009    provenance: SourceProvenanceV1,
1010}
1011
1012impl SourceConstructFactV1 {
1013    /// Construct one source construct fact.
1014    ///
1015    /// # Errors
1016    ///
1017    /// Returns [`SourceFactsError::ZeroConstructCount`] because every retained
1018    /// row is positive-presence evidence.
1019    pub fn new(
1020        source_order_index: usize,
1021        kind: SourceConstructKindV1,
1022        name: SourceTextV1,
1023        required: bool,
1024        count: u64,
1025        disposition: SourceLoaderDispositionV1,
1026        provenance: SourceProvenanceV1,
1027    ) -> Result<Self, SourceFactsError> {
1028        if count == 0 {
1029            return Err(SourceFactsError::ZeroConstructCount);
1030        }
1031        Ok(Self {
1032            source_order_index,
1033            kind,
1034            name,
1035            required,
1036            count,
1037            disposition,
1038            provenance,
1039        })
1040    }
1041
1042    /// Zero-based deterministic row order in this projection domain.
1043    pub const fn source_order_index(&self) -> usize {
1044        self.source_order_index
1045    }
1046
1047    /// Construct kind.
1048    pub const fn kind(&self) -> SourceConstructKindV1 {
1049        self.kind
1050    }
1051
1052    /// Bounded source construct spelling.
1053    pub const fn name(&self) -> &SourceTextV1 {
1054        &self.name
1055    }
1056
1057    /// Whether the source declaration makes the construct required.
1058    pub const fn required(&self) -> bool {
1059        self.required
1060    }
1061
1062    /// Number of source occurrences represented by this aggregate row.
1063    pub const fn count(&self) -> u64 {
1064        self.count
1065    }
1066
1067    /// AnimSmith-loader treatment.
1068    pub const fn disposition(&self) -> SourceLoaderDispositionV1 {
1069        self.disposition
1070    }
1071
1072    /// Source provenance.
1073    pub const fn provenance(&self) -> &SourceProvenanceV1 {
1074        &self.provenance
1075    }
1076
1077    fn retained_bytes(&self) -> usize {
1078        self.name
1079            .retained_bytes()
1080            .saturating_add(self.provenance.retained_bytes())
1081    }
1082}
1083
1084/// Source declaration kind that may refer to external content.
1085#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1086#[serde(rename_all = "snake_case")]
1087pub enum SourceResourceKindV1 {
1088    /// glTF buffer declaration.
1089    Buffer,
1090    /// glTF image declaration.
1091    Image,
1092    /// FBX texture declaration.
1093    Texture,
1094    /// FBX video declaration.
1095    Video,
1096    /// FBX geometry-cache declaration.
1097    Cache,
1098}
1099
1100/// Redacted classification of one resource locator declaration.
1101#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1102pub enum SourceResourceLocatorV1 {
1103    /// Payload is embedded in the primary container/buffer.
1104    Embedded,
1105    /// Payload is carried by a data URI; payload/spelling is not retained.
1106    DataUri,
1107    /// Bounded exact relative spelling, not normalized or deduplicated.
1108    Relative(SourceRelativeLocatorV1),
1109    /// Absolute locator; spelling is redacted.
1110    Absolute,
1111    /// Escaping/traversal locator; spelling is redacted.
1112    Escaping,
1113    /// Remote locator; spelling is redacted.
1114    Remote,
1115    /// Malformed locator; spelling is redacted.
1116    Malformed,
1117    /// Oversized locator; spelling is redacted.
1118    Oversized,
1119    /// Declaration has no locator.
1120    Missing,
1121}
1122
1123impl SourceResourceLocatorV1 {
1124    /// Classify a resource spelling while redacting unsafe or oversized input.
1125    ///
1126    /// This is lexical classification only. It deliberately does not
1127    /// normalize, deduplicate, open, or assign resource identity; #475 owns
1128    /// those dependency-closure operations.
1129    pub fn classify(value: &str) -> Self {
1130        if let Some(classification) = redacted_resource_locator(value) {
1131            return classification;
1132        }
1133        SourceTextV1::new(value).map_or(Self::Oversized, |value| {
1134            Self::Relative(SourceRelativeLocatorV1(value))
1135        })
1136    }
1137
1138    /// Exact UTF-8 bytes [`Self::classify`] would retain, without allocating.
1139    ///
1140    /// Loaders use this to reserve aggregate text capacity before constructing
1141    /// a safe-relative locator. Redacted classifications return zero.
1142    pub fn retained_relative_bytes(value: &str) -> usize {
1143        if redacted_resource_locator(value).is_none() {
1144            value.len()
1145        } else {
1146            0
1147        }
1148    }
1149
1150    fn retained_bytes(&self) -> usize {
1151        match self {
1152            Self::Relative(value) => value.0.retained_bytes(),
1153            _ => 0,
1154        }
1155    }
1156}
1157
1158fn redacted_resource_locator(value: &str) -> Option<SourceResourceLocatorV1> {
1159    if value
1160        .get(..5)
1161        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
1162    {
1163        return Some(SourceResourceLocatorV1::DataUri);
1164    }
1165    if value.len() > RAW_SOURCE_V1_MAX_TEXT_BYTES {
1166        return Some(SourceResourceLocatorV1::Oversized);
1167    }
1168    if value.is_empty() || value.chars().any(char::is_control) || malformed_percent_escape(value) {
1169        return Some(SourceResourceLocatorV1::Malformed);
1170    }
1171    if value.starts_with(['/', '\\'])
1172        || value.as_bytes().get(1).is_some_and(|value| *value == b':')
1173        || value
1174            .get(..5)
1175            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("file:"))
1176    {
1177        return Some(SourceResourceLocatorV1::Absolute);
1178    }
1179    if has_uri_scheme(value) {
1180        return Some(SourceResourceLocatorV1::Remote);
1181    }
1182    let mut escaped = false;
1183    let mut malformed = false;
1184    for component in value.split(['/', '\\']) {
1185        escaped |= component == ".." || is_encoded_dot_segment(component);
1186        malformed |= component.is_empty() || component == ".";
1187    }
1188    if escaped || contains_encoded_path_escape(value) {
1189        return Some(SourceResourceLocatorV1::Escaping);
1190    }
1191    if malformed {
1192        return Some(SourceResourceLocatorV1::Malformed);
1193    }
1194    None
1195}
1196
1197/// Validated bounded exact relative locator spelling.
1198#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1199pub struct SourceRelativeLocatorV1(SourceTextV1);
1200
1201impl SourceRelativeLocatorV1 {
1202    /// Exact relative spelling; no normalization has been applied.
1203    pub fn as_str(&self) -> &str {
1204        self.0.as_str()
1205    }
1206}
1207
1208fn malformed_percent_escape(value: &str) -> bool {
1209    let bytes = value.as_bytes();
1210    let mut index = 0;
1211    while index < bytes.len() {
1212        if bytes[index] != b'%' {
1213            index += 1;
1214            continue;
1215        }
1216        if bytes.get(index + 1).and_then(|value| hex(*value)).is_none()
1217            || bytes.get(index + 2).and_then(|value| hex(*value)).is_none()
1218        {
1219            return true;
1220        }
1221        index += 3;
1222    }
1223    false
1224}
1225
1226fn contains_encoded_path_escape(value: &str) -> bool {
1227    let lower = value.to_ascii_lowercase();
1228    lower.contains("%2f") || lower.contains("%5c") || lower.contains("%00")
1229}
1230
1231fn is_encoded_dot_segment(value: &str) -> bool {
1232    let bytes = value.as_bytes();
1233    let mut index = 0;
1234    let mut dots = 0;
1235    let mut encoded = false;
1236    while index < bytes.len() {
1237        if bytes[index] == b'.' {
1238            dots += 1;
1239            index += 1;
1240        } else if bytes.get(index..index + 3).is_some_and(|escape| {
1241            escape[0] == b'%' && escape[1] == b'2' && matches!(escape[2], b'e' | b'E')
1242        }) {
1243            dots += 1;
1244            encoded = true;
1245            index += 3;
1246        } else {
1247            return false;
1248        }
1249        if dots > 2 {
1250            return false;
1251        }
1252    }
1253    encoded && matches!(dots, 1 | 2)
1254}
1255
1256fn hex(value: u8) -> Option<u8> {
1257    match value {
1258        b'0'..=b'9' => Some(value - b'0'),
1259        b'a'..=b'f' => Some(value - b'a' + 10),
1260        b'A'..=b'F' => Some(value - b'A' + 10),
1261        _ => None,
1262    }
1263}
1264
1265fn has_uri_scheme(value: &str) -> bool {
1266    let Some((scheme, _)) = value.split_once(':') else {
1267        return false;
1268    };
1269    !scheme.is_empty()
1270        && scheme.as_bytes()[0].is_ascii_alphabetic()
1271        && scheme
1272            .bytes()
1273            .all(|value| value.is_ascii_alphanumeric() || matches!(value, b'+' | b'-' | b'.'))
1274}
1275
1276/// One source resource declaration, not a resolved dependency identity.
1277#[derive(Debug, Clone, PartialEq, Eq)]
1278pub struct SourceResourceReferenceV1 {
1279    source_order_index: usize,
1280    kind: SourceResourceKindV1,
1281    source_index: u64,
1282    locator: SourceResourceLocatorV1,
1283    disposition: SourceLoaderDispositionV1,
1284    provenance: SourceProvenanceV1,
1285}
1286
1287impl SourceResourceReferenceV1 {
1288    /// Construct one stable declaration row.
1289    pub fn new(
1290        source_order_index: usize,
1291        kind: SourceResourceKindV1,
1292        source_index: u64,
1293        locator: SourceResourceLocatorV1,
1294        disposition: SourceLoaderDispositionV1,
1295        provenance: SourceProvenanceV1,
1296    ) -> Self {
1297        Self {
1298            source_order_index,
1299            kind,
1300            source_index,
1301            locator,
1302            disposition,
1303            provenance,
1304        }
1305    }
1306
1307    /// Zero-based deterministic declaration order in this projection domain.
1308    pub const fn source_order_index(&self) -> usize {
1309        self.source_order_index
1310    }
1311
1312    /// Resource declaration kind.
1313    pub const fn kind(&self) -> SourceResourceKindV1 {
1314        self.kind
1315    }
1316
1317    /// Stable source/parser declaration index.
1318    pub const fn source_index(&self) -> u64 {
1319        self.source_index
1320    }
1321
1322    /// Redacted declaration classification.
1323    pub const fn locator(&self) -> &SourceResourceLocatorV1 {
1324        &self.locator
1325    }
1326
1327    /// AnimSmith-loader treatment of the declaration/content domain.
1328    pub const fn disposition(&self) -> SourceLoaderDispositionV1 {
1329        self.disposition
1330    }
1331
1332    /// Source provenance.
1333    pub const fn provenance(&self) -> &SourceProvenanceV1 {
1334        &self.provenance
1335    }
1336
1337    fn retained_bytes(&self) -> usize {
1338        self.locator
1339            .retained_bytes()
1340            .saturating_add(self.provenance.retained_bytes())
1341    }
1342}
1343
1344/// Deterministic work retained for one V1 projection.
1345#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1346pub struct SourceProjectionWorkV1 {
1347    inspected_rows: usize,
1348    retained_rows: usize,
1349    retained_text_bytes: usize,
1350    max_traversal_depth: usize,
1351}
1352
1353impl SourceProjectionWorkV1 {
1354    /// Rows inspected before bounded projection stopped affected sets.
1355    pub const fn inspected_rows(self) -> usize {
1356        self.inspected_rows
1357    }
1358
1359    /// Rows retained across clips/channels, constructs, and resource declarations.
1360    pub const fn retained_rows(self) -> usize {
1361        self.retained_rows
1362    }
1363
1364    /// Total UTF-8 bytes retained in source names and logical locators.
1365    pub const fn retained_text_bytes(self) -> usize {
1366        self.retained_text_bytes
1367    }
1368
1369    /// Greatest traversal depth inspected, capped at V1 limit plus one.
1370    pub const fn max_traversal_depth(self) -> usize {
1371        self.max_traversal_depth
1372    }
1373}
1374
1375/// Independently bounded enumerable projection domain.
1376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1377pub enum SourceFactDomainV1 {
1378    /// Clips/takes and nested channel/property rows.
1379    Clips,
1380    /// Extension/custom/unmodeled construct rows.
1381    Constructs,
1382    /// External-resource declaration rows.
1383    Resources,
1384}
1385
1386impl SourceFactDomainV1 {
1387    const fn index(self) -> usize {
1388        match self {
1389            Self::Clips => 0,
1390            Self::Constructs => 1,
1391            Self::Resources => 2,
1392        }
1393    }
1394}
1395
1396/// Immutable owned V1 facts bound into a [`LoadedSource`].
1397#[derive(Debug, Clone, PartialEq)]
1398pub struct RawSourceFactsV1 {
1399    format: SourceFormatV1,
1400    primary_identity: InputIdentity,
1401    linear_unit: SourceObservationV1<SourceLinearUnitV1>,
1402    coordinate_basis: SourceObservationV1<SourceCoordinateBasisV1>,
1403    frames_per_second: SourceObservationV1<SourceFramesPerSecondV1>,
1404    clips: SourceFactSetV1<SourceClipFactV1>,
1405    constructs: SourceFactSetV1<SourceConstructFactV1>,
1406    resources: SourceFactSetV1<SourceResourceReferenceV1>,
1407    work: SourceProjectionWorkV1,
1408}
1409
1410/// Builder used by format loaders to create a bounded immutable V1 projection.
1411pub struct RawSourceFactsBuilderV1 {
1412    facts: RawSourceFactsV1,
1413    stopped: [bool; 3],
1414}
1415
1416fn unavailable_observation<T>() -> SourceObservationV1<T> {
1417    SourceObservationV1::unavailable(
1418        SourceUnavailableReasonV1::ParserUnavailable,
1419        None,
1420        SourceLoaderDispositionV1::Unknown,
1421    )
1422}
1423
1424fn replace_scalar_observation<T>(
1425    work: &mut SourceProjectionWorkV1,
1426    slot: &mut SourceObservationV1<T>,
1427    value: SourceObservationV1<T>,
1428) -> bool {
1429    let previous_bytes = slot.retained_bytes();
1430    let value_bytes = value.retained_bytes();
1431    let baseline = work.retained_text_bytes.saturating_sub(previous_bytes);
1432    if baseline
1433        .checked_add(value_bytes)
1434        .is_some_and(|total| total <= RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES)
1435    {
1436        work.retained_text_bytes = baseline.saturating_add(value_bytes);
1437        *slot = value;
1438        true
1439    } else {
1440        work.retained_text_bytes = baseline;
1441        *slot = SourceObservationV1::unavailable(
1442            SourceUnavailableReasonV1::ProjectionBudgetExceeded,
1443            None,
1444            SourceLoaderDispositionV1::Unknown,
1445        );
1446        false
1447    }
1448}
1449
1450impl RawSourceFactsBuilderV1 {
1451    /// Begin one projection bound to the exact primary bytes already captured by the loader.
1452    pub fn new(format: SourceFormatV1, primary_identity: InputIdentity) -> Self {
1453        Self {
1454            facts: RawSourceFactsV1 {
1455                format,
1456                primary_identity,
1457                linear_unit: unavailable_observation(),
1458                coordinate_basis: unavailable_observation(),
1459                frames_per_second: unavailable_observation(),
1460                clips: SourceFactSetV1::unavailable(SourceUnavailableReasonV1::ParserUnavailable),
1461                constructs: SourceFactSetV1::unavailable(
1462                    SourceUnavailableReasonV1::ParserUnavailable,
1463                ),
1464                resources: SourceFactSetV1::unavailable(
1465                    SourceUnavailableReasonV1::ParserUnavailable,
1466                ),
1467                work: SourceProjectionWorkV1::default(),
1468            },
1469            stopped: [false; 3],
1470        }
1471    }
1472
1473    /// Set the source linear-unit observation.
1474    ///
1475    /// Returns `false` when its provenance would exceed the aggregate text
1476    /// budget; the stored observation is then unavailable with a budget reason.
1477    pub fn set_linear_unit(&mut self, value: SourceObservationV1<SourceLinearUnitV1>) -> bool {
1478        replace_scalar_observation(&mut self.facts.work, &mut self.facts.linear_unit, value)
1479    }
1480
1481    /// Set the signed coordinate-basis observation.
1482    pub fn set_coordinate_basis(
1483        &mut self,
1484        value: SourceObservationV1<SourceCoordinateBasisV1>,
1485    ) -> bool {
1486        replace_scalar_observation(
1487            &mut self.facts.work,
1488            &mut self.facts.coordinate_basis,
1489            value,
1490        )
1491    }
1492
1493    /// Set the source frame-rate observation.
1494    pub fn set_frames_per_second(
1495        &mut self,
1496        value: SourceObservationV1<SourceFramesPerSecondV1>,
1497    ) -> bool {
1498        replace_scalar_observation(
1499            &mut self.facts.work,
1500            &mut self.facts.frames_per_second,
1501            value,
1502        )
1503    }
1504
1505    /// Observation-row capacity remaining across all enumerable domains.
1506    ///
1507    /// Loaders use this before allocating the next nested channel prefix.
1508    pub const fn remaining_observation_rows(&self) -> usize {
1509        RAW_SOURCE_V1_MAX_OBSERVATIONS.saturating_sub(self.facts.work.retained_rows)
1510    }
1511
1512    /// Clip/take identity-row capacity remaining.
1513    pub fn remaining_clip_rows(&self) -> usize {
1514        RAW_SOURCE_V1_MAX_CLIPS.saturating_sub(self.facts.clips.rows.len())
1515    }
1516
1517    /// Resource-reference row capacity remaining.
1518    pub fn remaining_resource_rows(&self) -> usize {
1519        RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES.saturating_sub(self.facts.resources.rows.len())
1520    }
1521
1522    /// Current raw resource-declaration coverage for closure capture.
1523    pub const fn resource_coverage(&self) -> SourceSetCoverageV1 {
1524        self.facts.resources.coverage
1525    }
1526
1527    /// Retained raw resource-declaration prefix for closure capture.
1528    pub fn resource_rows(&self) -> &[SourceResourceReferenceV1] {
1529        &self.facts.resources.rows
1530    }
1531
1532    /// Exact primary identity this projection is bound to.
1533    pub const fn primary_identity(&self) -> &InputIdentity {
1534        &self.facts.primary_identity
1535    }
1536
1537    /// Retained UTF-8 byte capacity remaining.
1538    ///
1539    /// Loaders use this before cloning the next source name or locator.
1540    pub const fn remaining_text_bytes(&self) -> usize {
1541        RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES.saturating_sub(self.facts.work.retained_text_bytes)
1542    }
1543
1544    /// Mark a row domain unavailable and discard any retained prefix.
1545    pub fn mark_unavailable(
1546        &mut self,
1547        domain: SourceFactDomainV1,
1548        reason: SourceUnavailableReasonV1,
1549    ) {
1550        let (retained_rows, retained_bytes) = match domain {
1551            SourceFactDomainV1::Clips => self
1552                .facts
1553                .clips
1554                .rows
1555                .iter()
1556                .map(|clip| (clip.retained_row_count(), clip.retained_bytes()))
1557                .fold(
1558                    (0usize, 0usize),
1559                    |(rows, bytes), (clip_rows, clip_bytes)| {
1560                        (
1561                            rows.saturating_add(clip_rows),
1562                            bytes.saturating_add(clip_bytes),
1563                        )
1564                    },
1565                ),
1566            SourceFactDomainV1::Constructs => (
1567                self.facts.constructs.rows.len(),
1568                self.facts
1569                    .constructs
1570                    .rows
1571                    .iter()
1572                    .map(SourceConstructFactV1::retained_bytes)
1573                    .fold(0usize, usize::saturating_add),
1574            ),
1575            SourceFactDomainV1::Resources => (
1576                self.facts.resources.rows.len(),
1577                self.facts
1578                    .resources
1579                    .rows
1580                    .iter()
1581                    .map(SourceResourceReferenceV1::retained_bytes)
1582                    .fold(0usize, usize::saturating_add),
1583            ),
1584        };
1585        self.facts.work.retained_rows = self.facts.work.retained_rows.saturating_sub(retained_rows);
1586        self.facts.work.retained_text_bytes = self
1587            .facts
1588            .work
1589            .retained_text_bytes
1590            .saturating_sub(retained_bytes);
1591        self.stopped[domain.index()] = true;
1592        match domain {
1593            SourceFactDomainV1::Clips => self.facts.clips = SourceFactSetV1::unavailable(reason),
1594            SourceFactDomainV1::Constructs => {
1595                self.facts.constructs = SourceFactSetV1::unavailable(reason)
1596            }
1597            SourceFactDomainV1::Resources => {
1598                self.facts.resources = SourceFactSetV1::unavailable(reason)
1599            }
1600        }
1601    }
1602
1603    /// Mark a retained row domain partial without removing positive-presence rows.
1604    pub fn mark_partial(&mut self, domain: SourceFactDomainV1, reason: SourceUnavailableReasonV1) {
1605        if !self.stopped[domain.index()] {
1606            *self.set_for_domain_mut(domain) = SourceSetCoverageV1::partial(reason);
1607        }
1608    }
1609
1610    /// Confirm exhaustive traversal for one row domain.
1611    ///
1612    /// This proves absence for an empty set only when no earlier partial or
1613    /// terminal-unavailable condition was recorded.
1614    pub fn mark_complete(&mut self, domain: SourceFactDomainV1) {
1615        if !self.stopped[domain.index()]
1616            && matches!(
1617                self.set_for_domain_mut(domain).state(),
1618                SourceSetCoverageStateV1::Unavailable
1619            )
1620        {
1621            *self.set_for_domain_mut(domain) = SourceSetCoverageV1::complete();
1622        }
1623    }
1624
1625    /// Record the terminal N+1 row without constructing or cloning it.
1626    ///
1627    /// Loaders call this once when a public remaining-capacity accessor reaches
1628    /// zero, then stop projection work for that set.
1629    pub fn mark_budget_exceeded(&mut self, domain: SourceFactDomainV1) {
1630        if self.stopped[domain.index()] {
1631            return;
1632        }
1633        self.facts.work.inspected_rows = self.facts.work.inspected_rows.saturating_add(1);
1634        self.stop_for_budget(domain);
1635    }
1636
1637    /// Record source traversal work and stop the affected domain at depth N+1.
1638    ///
1639    /// Returns `true` while projection may continue for that domain.
1640    pub fn observe_traversal_depth(&mut self, domain: SourceFactDomainV1, depth: usize) -> bool {
1641        if self.stopped[domain.index()] {
1642            return false;
1643        }
1644        self.facts.work.max_traversal_depth = self
1645            .facts
1646            .work
1647            .max_traversal_depth
1648            .max(depth.min(RAW_SOURCE_V1_MAX_TRAVERSAL_DEPTH + 1));
1649        if depth > RAW_SOURCE_V1_MAX_TRAVERSAL_DEPTH {
1650            self.stop_for_budget(domain);
1651            return false;
1652        }
1653        true
1654    }
1655
1656    /// Retain one source-order clip/take row and its channel prefix.
1657    ///
1658    /// Returns `true` when the clip identity row was retained. Budget excess
1659    /// never becomes a loader failure.
1660    pub fn push_clip(&mut self, mut clip: SourceClipFactV1) -> bool {
1661        if self.stopped[SourceFactDomainV1::Clips.index()] {
1662            return false;
1663        }
1664        if self.facts.clips.rows.len() >= RAW_SOURCE_V1_MAX_CLIPS {
1665            self.mark_budget_exceeded(SourceFactDomainV1::Clips);
1666            return false;
1667        }
1668        let remaining_rows =
1669            RAW_SOURCE_V1_MAX_OBSERVATIONS.saturating_sub(self.facts.work.retained_rows);
1670        if remaining_rows == 0 {
1671            self.mark_budget_exceeded(SourceFactDomainV1::Clips);
1672            return false;
1673        }
1674        let original_rows = clip.retained_row_count();
1675        let supplied_budget_prefix = matches!(
1676            clip.channels.coverage(),
1677            SourceSetCoverageV1 {
1678                state: SourceSetCoverageStateV1::Partial,
1679                reason: Some(SourceUnavailableReasonV1::ProjectionBudgetExceeded),
1680            }
1681        );
1682        let mut builder_truncated = false;
1683        if clip.retained_row_count() > remaining_rows {
1684            clip.truncate_channels(remaining_rows - 1);
1685            builder_truncated = true;
1686        }
1687        if !clip.truncate_channels_to_text(self.remaining_text_bytes()) {
1688            self.mark_budget_exceeded(SourceFactDomainV1::Clips);
1689            return false;
1690        }
1691        builder_truncated |= clip.retained_row_count() < original_rows;
1692        let retained_rows = clip.retained_row_count();
1693        let inspected_rows = if builder_truncated || supplied_budget_prefix {
1694            retained_rows.saturating_add(1)
1695        } else {
1696            retained_rows
1697        };
1698        self.facts.work.inspected_rows = self
1699            .facts
1700            .work
1701            .inspected_rows
1702            .saturating_add(inspected_rows);
1703        if builder_truncated || supplied_budget_prefix {
1704            self.stop_for_budget(SourceFactDomainV1::Clips);
1705        }
1706        self.retain_work(clip.retained_row_count(), clip.retained_bytes());
1707        self.facts.clips.rows.push(clip);
1708        true
1709    }
1710
1711    /// Retain one deterministic extension/custom construct row.
1712    pub fn push_construct(&mut self, row: SourceConstructFactV1) -> bool {
1713        if self.stopped[SourceFactDomainV1::Constructs.index()] {
1714            return false;
1715        }
1716        if !self.can_retain_row(row.retained_bytes()) {
1717            self.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1718            return false;
1719        }
1720        self.facts.work.inspected_rows = self.facts.work.inspected_rows.saturating_add(1);
1721        self.retain_work(1, row.retained_bytes());
1722        self.facts.constructs.rows.push(row);
1723        true
1724    }
1725
1726    /// Retain one deterministic resource-declaration row.
1727    pub fn push_resource(&mut self, row: SourceResourceReferenceV1) -> bool {
1728        if self.stopped[SourceFactDomainV1::Resources.index()] {
1729            return false;
1730        }
1731        if self.facts.resources.rows.len() >= RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES
1732            || !self.can_retain_row(row.retained_bytes())
1733        {
1734            self.mark_budget_exceeded(SourceFactDomainV1::Resources);
1735            return false;
1736        }
1737        self.facts.work.inspected_rows = self.facts.work.inspected_rows.saturating_add(1);
1738        self.retain_work(1, row.retained_bytes());
1739        self.facts.resources.rows.push(row);
1740        true
1741    }
1742
1743    /// Bind the retained facts to the normalized document from the same parse.
1744    ///
1745    /// # Errors
1746    ///
1747    /// Returns [`SourceFactsError`] when source row ordering or normalized clip
1748    /// mappings contradict the document being bound.
1749    pub fn finish(mut self, document: Document) -> Result<LoadedSource, SourceFactsError> {
1750        self.qualify_unfinished_positive_rows();
1751        let closure = DependencyClosureV1::capture_unavailable(
1752            self.facts.primary_identity.clone(),
1753            self.facts.resources.coverage,
1754        );
1755        self.finish_with_dependency_closure(document, closure)
1756    }
1757
1758    /// Bind retained facts and dependency closure to one normalized document.
1759    ///
1760    /// This is the format-loader completion path once bounded resource capture
1761    /// has run. The closure primary identity and retained reference prefix must
1762    /// match this raw projection exactly.
1763    ///
1764    /// # Errors
1765    ///
1766    /// Returns [`SourceFactsError`] for source row/document contradictions or
1767    /// a mismatched dependency closure.
1768    pub fn finish_with_dependency_closure(
1769        mut self,
1770        document: Document,
1771        dependency_closure: DependencyClosureV1,
1772    ) -> Result<LoadedSource, SourceFactsError> {
1773        self.qualify_unfinished_positive_rows();
1774        validate_clip_rows(&self.facts.clips, document.clips.len())?;
1775        validate_ordered_rows(&self.facts.constructs, &self.facts.resources)?;
1776        dependency_closure.validate_against(
1777            self.facts.format,
1778            &self.facts.primary_identity,
1779            &self.facts.resources,
1780        )?;
1781        Ok(LoadedSource {
1782            document,
1783            facts: self.facts,
1784            dependency_closure,
1785            exact_source_timing: None,
1786            raw_gltf_addressability_inventory: None,
1787            raw_scene_attachment_inventory: None,
1788            raw_transform_path_inventory: None,
1789        })
1790    }
1791
1792    fn can_retain_text(&self, bytes: usize) -> bool {
1793        self.facts
1794            .work
1795            .retained_text_bytes
1796            .checked_add(bytes)
1797            .is_some_and(|total| total <= RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES)
1798    }
1799
1800    fn can_retain_row(&self, bytes: usize) -> bool {
1801        self.facts.work.retained_rows < RAW_SOURCE_V1_MAX_OBSERVATIONS
1802            && self.can_retain_text(bytes)
1803    }
1804
1805    fn retain_work(&mut self, rows: usize, bytes: usize) {
1806        self.facts.work.retained_rows = self.facts.work.retained_rows.saturating_add(rows);
1807        self.facts.work.retained_text_bytes =
1808            self.facts.work.retained_text_bytes.saturating_add(bytes);
1809    }
1810
1811    fn set_for_domain_mut(&mut self, domain: SourceFactDomainV1) -> &mut SourceSetCoverageV1 {
1812        match domain {
1813            SourceFactDomainV1::Clips => &mut self.facts.clips.coverage,
1814            SourceFactDomainV1::Constructs => &mut self.facts.constructs.coverage,
1815            SourceFactDomainV1::Resources => &mut self.facts.resources.coverage,
1816        }
1817    }
1818
1819    fn stop_for_budget(&mut self, domain: SourceFactDomainV1) {
1820        *self.set_for_domain_mut(domain) =
1821            SourceSetCoverageV1::partial(SourceUnavailableReasonV1::ProjectionBudgetExceeded);
1822        self.stopped[domain.index()] = true;
1823    }
1824
1825    fn qualify_unfinished_positive_rows(&mut self) {
1826        for domain in [
1827            SourceFactDomainV1::Clips,
1828            SourceFactDomainV1::Constructs,
1829            SourceFactDomainV1::Resources,
1830        ] {
1831            let has_rows = match domain {
1832                SourceFactDomainV1::Clips => !self.facts.clips.rows.is_empty(),
1833                SourceFactDomainV1::Constructs => !self.facts.constructs.rows.is_empty(),
1834                SourceFactDomainV1::Resources => !self.facts.resources.rows.is_empty(),
1835            };
1836            if has_rows
1837                && matches!(
1838                    self.set_for_domain_mut(domain).state(),
1839                    SourceSetCoverageStateV1::Unavailable
1840                )
1841            {
1842                *self.set_for_domain_mut(domain) =
1843                    SourceSetCoverageV1::partial(SourceUnavailableReasonV1::ParserUnavailable);
1844            }
1845        }
1846    }
1847}
1848
1849fn validate_clip_rows(
1850    clips: &SourceFactSetV1<SourceClipFactV1>,
1851    normalized_clip_count: usize,
1852) -> Result<(), SourceFactsError> {
1853    for (expected_clip_index, clip) in clips.rows.iter().enumerate() {
1854        if clip.source_clip_index != expected_clip_index {
1855            return Err(SourceFactsError::NonCanonicalClipIndex {
1856                expected: expected_clip_index,
1857                actual: clip.source_clip_index,
1858            });
1859        }
1860        if let SourceObservationStateV1::Observed(index) = clip.normalized_clip_index.state()
1861            && *index >= normalized_clip_count
1862        {
1863            return Err(SourceFactsError::NormalizedClipIndexOutOfRange {
1864                index: *index,
1865                clip_count: normalized_clip_count,
1866            });
1867        }
1868        for (expected_channel_index, channel) in clip.channels.rows.iter().enumerate() {
1869            if channel.source_channel_index != expected_channel_index {
1870                return Err(SourceFactsError::NonCanonicalChannelIndex {
1871                    source_clip_index: clip.source_clip_index,
1872                    expected: expected_channel_index,
1873                    actual: channel.source_channel_index,
1874                });
1875            }
1876        }
1877    }
1878    Ok(())
1879}
1880
1881fn validate_ordered_rows(
1882    constructs: &SourceFactSetV1<SourceConstructFactV1>,
1883    resources: &SourceFactSetV1<SourceResourceReferenceV1>,
1884) -> Result<(), SourceFactsError> {
1885    for (expected, row) in constructs.rows.iter().enumerate() {
1886        if row.source_order_index != expected {
1887            return Err(SourceFactsError::NonCanonicalConstructOrder {
1888                expected,
1889                actual: row.source_order_index,
1890            });
1891        }
1892    }
1893    for (expected, row) in resources.rows.iter().enumerate() {
1894        if row.source_order_index != expected {
1895            return Err(SourceFactsError::NonCanonicalResourceOrder {
1896                expected,
1897                actual: row.source_order_index,
1898            });
1899        }
1900    }
1901    Ok(())
1902}
1903
1904/// Immutable source-plus-normalized-document owner returned by format loaders.
1905///
1906/// This type intentionally provides no mutable document reference and no
1907/// operation that separates live facts from a mutable `Document`.
1908pub struct LoadedSource {
1909    document: Document,
1910    facts: RawSourceFactsV1,
1911    dependency_closure: DependencyClosureV1,
1912    exact_source_timing: Option<ExactSourceTimingV1>,
1913    raw_gltf_addressability_inventory: Option<RawGltfAddressabilityInventoryV1>,
1914    raw_scene_attachment_inventory: Option<RawSceneAttachmentInventoryV1>,
1915    raw_transform_path_inventory: Option<RawTransformPathInventoryV1>,
1916}
1917
1918impl fmt::Debug for LoadedSource {
1919    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1920        formatter
1921            .debug_struct("LoadedSource")
1922            .field("format", &self.facts.format)
1923            .field("primary_identity", &self.facts.primary_identity)
1924            .field("dependency_closure", &self.dependency_closure)
1925            .field("exact_source_timing", &self.exact_source_timing)
1926            .field(
1927                "raw_gltf_addressability_inventory",
1928                &self.raw_gltf_addressability_inventory,
1929            )
1930            .field(
1931                "raw_scene_attachment_inventory",
1932                &self.raw_scene_attachment_inventory,
1933            )
1934            .field(
1935                "raw_transform_path_inventory",
1936                &self.raw_transform_path_inventory,
1937            )
1938            .field("work", &self.facts.work)
1939            .finish_non_exhaustive()
1940    }
1941}
1942
1943impl LoadedSource {
1944    /// Borrow the normalized document without permitting mutation beside live facts.
1945    pub const fn document(&self) -> &Document {
1946        &self.document
1947    }
1948
1949    /// Borrow the V1 facts together with the canonical existing source skeleton projection.
1950    pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
1951        SourceFactsViewV1 {
1952            facts: &self.facts,
1953            source_skeleton: &self.document.assets.source_skeleton,
1954        }
1955    }
1956
1957    /// Borrow the bounded dependency closure captured during this same load.
1958    pub const fn dependency_closure(&self) -> &DependencyClosureV1 {
1959        &self.dependency_closure
1960    }
1961
1962    /// Attach bounded exact timing evidence produced by the same loader parse.
1963    ///
1964    /// # Errors
1965    ///
1966    /// Returns [`ExactSourceTimingContractError`] when the exact clip prefix or
1967    /// coverage does not match the existing V1 clip domain.
1968    pub fn with_exact_source_timing(
1969        mut self,
1970        timing: ExactSourceTimingV1,
1971    ) -> Result<Self, ExactSourceTimingContractError> {
1972        if timing.clips().len() != self.facts.clips.rows.len() {
1973            return Err(ExactSourceTimingContractError::ClipCountMismatch {
1974                exact: timing.clips().len(),
1975                source_count: self.facts.clips.rows.len(),
1976            });
1977        }
1978        if timing.clip_coverage() != self.facts.clips.coverage {
1979            return Err(ExactSourceTimingContractError::ClipCoverageMismatch);
1980        }
1981        self.exact_source_timing = Some(timing);
1982        Ok(self)
1983    }
1984
1985    /// Borrow exact source timing evidence when this loader retained it.
1986    ///
1987    /// Sources produced by callers that retain only legacy V1 facts return `None`.
1988    pub const fn exact_source_timing(&self) -> Option<&ExactSourceTimingV1> {
1989        self.exact_source_timing.as_ref()
1990    }
1991
1992    /// Attach bounded raw glTF addressability evidence from this exact load.
1993    ///
1994    /// # Errors
1995    ///
1996    /// Returns [`RawGltfAddressabilityBindingErrorV1`] when the inventory is
1997    /// invalid, belongs to a non-glTF source, or disagrees with the exact
1998    /// primary input or dependency closure already bound to this source.
1999    pub fn with_raw_gltf_addressability_inventory(
2000        mut self,
2001        inventory: RawGltfAddressabilityInventoryV1,
2002    ) -> Result<Self, RawGltfAddressabilityBindingErrorV1> {
2003        inventory
2004            .validate()
2005            .map_err(|_| RawGltfAddressabilityBindingErrorV1::InvalidInventory)?;
2006        if !matches!(
2007            self.facts.format,
2008            SourceFormatV1::GltfJson | SourceFormatV1::Glb
2009        ) {
2010            return Err(RawGltfAddressabilityBindingErrorV1::UnsupportedSourceFormat);
2011        }
2012        if inventory.primary_input() != &self.facts.primary_identity {
2013            return Err(RawGltfAddressabilityBindingErrorV1::PrimaryIdentityMismatch);
2014        }
2015        if inventory.dependency_closure() != &self.dependency_closure {
2016            return Err(RawGltfAddressabilityBindingErrorV1::DependencyClosureMismatch);
2017        }
2018        self.raw_gltf_addressability_inventory = Some(inventory);
2019        Ok(self)
2020    }
2021
2022    /// Borrow same-load raw glTF scene/node/skin/path evidence when retained.
2023    pub const fn raw_gltf_addressability_inventory(
2024        &self,
2025    ) -> Option<&RawGltfAddressabilityInventoryV1> {
2026        self.raw_gltf_addressability_inventory.as_ref()
2027    }
2028
2029    /// Attach bounded raw scene/attachment evidence from this exact glTF load.
2030    ///
2031    /// # Errors
2032    ///
2033    /// Returns [`RawSceneAttachmentBindingError`] when the inventory does not
2034    /// bind this source's exact primary identity, a glTF container, or the
2035    /// canonical source-skeleton evidence retained beside this document.
2036    pub fn with_raw_scene_attachment_inventory(
2037        mut self,
2038        inventory: RawSceneAttachmentInventoryV1,
2039    ) -> Result<Self, RawSceneAttachmentBindingError> {
2040        if !matches!(
2041            self.facts.format,
2042            SourceFormatV1::GltfJson | SourceFormatV1::Glb
2043        ) {
2044            return Err(RawSceneAttachmentBindingError::UnsupportedSourceFormat);
2045        }
2046        if inventory.primary_input() != &self.facts.primary_identity {
2047            return Err(RawSceneAttachmentBindingError::PrimaryIdentityMismatch);
2048        }
2049        if inventory.source_skeleton()
2050            != &source_skeleton_evidence(&self.document.assets.source_skeleton)
2051        {
2052            return Err(RawSceneAttachmentBindingError::SourceSkeletonMismatch);
2053        }
2054        self.raw_scene_attachment_inventory = Some(inventory);
2055        Ok(self)
2056    }
2057
2058    /// Borrow same-load raw scene/attachment evidence when the loader retained it.
2059    ///
2060    /// Legacy and non-glTF producers return `None` rather than inferring
2061    /// source presence from normalized mesh assets.
2062    pub const fn raw_scene_attachment_inventory(&self) -> Option<&RawSceneAttachmentInventoryV1> {
2063        self.raw_scene_attachment_inventory.as_ref()
2064    }
2065
2066    /// Attach bounded raw FBX transform-path evidence from this exact load.
2067    ///
2068    /// # Errors
2069    ///
2070    /// Returns [`RawTransformPathBindingError`] when the inventory is invalid,
2071    /// was not projected from FBX, identifies different primary bytes, or its
2072    /// same-load normalized bone count disagrees with the document.
2073    pub fn with_raw_transform_path_inventory(
2074        mut self,
2075        inventory: RawTransformPathInventoryV1,
2076    ) -> Result<Self, RawTransformPathBindingError> {
2077        inventory
2078            .validate()
2079            .map_err(|_| RawTransformPathBindingError::InvalidInventory)?;
2080        if self.facts.format != SourceFormatV1::Fbx
2081            || inventory.source_format() != SourceFormatV1::Fbx
2082        {
2083            return Err(RawTransformPathBindingError::UnsupportedSourceFormat);
2084        }
2085        if inventory.primary_input() != &self.facts.primary_identity {
2086            return Err(RawTransformPathBindingError::PrimaryIdentityMismatch);
2087        }
2088        if inventory.projected_bone_count() != self.document.skeleton.bones.len() as u64 {
2089            return Err(RawTransformPathBindingError::ProjectedBoneCountMismatch);
2090        }
2091        self.raw_transform_path_inventory = Some(inventory);
2092        Ok(self)
2093    }
2094
2095    /// Borrow same-load raw FBX transform-path evidence when retained.
2096    pub const fn raw_transform_path_inventory(&self) -> Option<&RawTransformPathInventoryV1> {
2097        self.raw_transform_path_inventory.as_ref()
2098    }
2099
2100    /// Consume the owner and deliberately discard importer-sensitive source facts.
2101    pub fn into_document(self) -> Document {
2102        self.document
2103    }
2104}
2105
2106fn source_skeleton_evidence(source: &SourceSkeletonAssets) -> RawSourceSkeletonEvidenceV1 {
2107    RawSourceSkeletonEvidenceV1::new(
2108        match source.coverage {
2109            SourceSkeletonCoverage::Complete => RawSceneAttachmentCoverageV1::Complete,
2110            SourceSkeletonCoverage::Unavailable => RawSceneAttachmentCoverageV1::Unavailable,
2111        },
2112        source.nodes.len() as u64,
2113        source.skins.len() as u64,
2114    )
2115}
2116
2117/// An attempted raw scene/attachment sidecar did not bind this loaded source.
2118#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
2119#[non_exhaustive]
2120pub enum RawSceneAttachmentBindingError {
2121    /// Raw scene/attachment inventories are currently defined only for glTF and GLB loads.
2122    #[error("raw scene/attachment inventory requires a glTF or GLB source")]
2123    UnsupportedSourceFormat,
2124    /// The inventory was produced from different primary bytes.
2125    #[error("raw scene/attachment inventory primary input does not match the loaded source")]
2126    PrimaryIdentityMismatch,
2127    /// The inventory's source-skeleton evidence does not match this loaded source.
2128    #[error(
2129        "raw scene/attachment inventory source-skeleton evidence does not match the loaded source"
2130    )]
2131    SourceSkeletonMismatch,
2132}
2133
2134/// An attempted raw glTF addressability sidecar did not bind this loaded source.
2135#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
2136#[non_exhaustive]
2137pub enum RawGltfAddressabilityBindingErrorV1 {
2138    /// Raw glTF addressability inventories require glTF JSON or GLB input.
2139    #[error("raw glTF addressability inventory requires a glTF or GLB source")]
2140    UnsupportedSourceFormat,
2141    /// The standalone inventory failed semantic validation.
2142    #[error("raw glTF addressability inventory is invalid")]
2143    InvalidInventory,
2144    /// The inventory identifies different primary bytes.
2145    #[error("raw glTF addressability inventory primary input does not match loaded source")]
2146    PrimaryIdentityMismatch,
2147    /// The inventory embeds a different dependency-closure record.
2148    #[error("raw glTF addressability inventory dependency closure does not match loaded source")]
2149    DependencyClosureMismatch,
2150}
2151
2152/// An attempted raw transform-path sidecar did not bind this loaded source.
2153#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
2154#[non_exhaustive]
2155pub enum RawTransformPathBindingError {
2156    /// Raw transform-path inventories V1 are currently defined only for FBX loads.
2157    #[error("raw transform-path inventory requires an FBX source")]
2158    UnsupportedSourceFormat,
2159    /// The inventory failed its standalone semantic validation.
2160    #[error("raw transform-path inventory is invalid")]
2161    InvalidInventory,
2162    /// The inventory was produced from different primary bytes.
2163    #[error("raw transform-path inventory primary input does not match the loaded source")]
2164    PrimaryIdentityMismatch,
2165    /// Same-load normalized bone cardinality does not match the document.
2166    #[error("raw transform-path inventory projected bone count does not match the document")]
2167    ProjectedBoneCountMismatch,
2168}
2169
2170/// Borrowing view over V1 facts and canonical source skeleton evidence.
2171#[derive(Debug, Clone, Copy)]
2172pub struct SourceFactsViewV1<'a> {
2173    facts: &'a RawSourceFactsV1,
2174    source_skeleton: &'a SourceSkeletonAssets,
2175}
2176
2177impl<'a> SourceFactsViewV1<'a> {
2178    /// Semantic identity of this in-memory vocabulary.
2179    pub const fn contract_id(self) -> &'static str {
2180        RAW_SOURCE_FACTS_V1_ID
2181    }
2182
2183    /// Exact source container kind captured by the loader.
2184    pub const fn format(self) -> SourceFormatV1 {
2185        self.facts.format
2186    }
2187
2188    /// SHA-256 and byte count of the exact primary bytes parsed by the loader.
2189    pub const fn primary_identity(self) -> &'a InputIdentity {
2190        &self.facts.primary_identity
2191    }
2192
2193    /// Source linear-unit observation.
2194    pub const fn linear_unit(self) -> &'a SourceObservationV1<SourceLinearUnitV1> {
2195        &self.facts.linear_unit
2196    }
2197
2198    /// Signed source coordinate-basis observation.
2199    pub const fn coordinate_basis(self) -> &'a SourceObservationV1<SourceCoordinateBasisV1> {
2200        &self.facts.coordinate_basis
2201    }
2202
2203    /// Source frame-rate observation.
2204    pub const fn frames_per_second(self) -> &'a SourceObservationV1<SourceFramesPerSecondV1> {
2205        &self.facts.frames_per_second
2206    }
2207
2208    /// Source clip/take rows and nested channel coverage.
2209    pub const fn clips(self) -> &'a SourceFactSetV1<SourceClipFactV1> {
2210        &self.facts.clips
2211    }
2212
2213    /// Source extension/custom construct rows.
2214    pub const fn constructs(self) -> &'a SourceFactSetV1<SourceConstructFactV1> {
2215        &self.facts.constructs
2216    }
2217
2218    /// Source resource declarations; these are not resolved dependency identities.
2219    pub const fn resources(self) -> &'a SourceFactSetV1<SourceResourceReferenceV1> {
2220        &self.facts.resources
2221    }
2222
2223    /// Canonical pre-existing source node/skin evidence, never copied into the sidecar.
2224    pub const fn source_skeleton(self) -> &'a SourceSkeletonAssets {
2225        self.source_skeleton
2226    }
2227
2228    /// Explicit projection work counters.
2229    pub const fn work(self) -> SourceProjectionWorkV1 {
2230        self.facts.work
2231    }
2232}
2233
2234/// Invalid raw-source fact or document-binding invariant.
2235#[derive(Debug, thiserror::Error, PartialEq, Eq)]
2236#[non_exhaustive]
2237pub enum SourceFactsError {
2238    /// One retained source string exceeds the public V1 limit.
2239    #[error("source text is {bytes} bytes, exceeding the V1 limit of {limit}")]
2240    TextTooLong {
2241        /// Observed UTF-8 byte count.
2242        bytes: usize,
2243        /// Public V1 limit.
2244        limit: usize,
2245    },
2246    /// A provenance locator is outside the generated source-logical vocabulary.
2247    #[error("source logical locator is invalid or unsafe")]
2248    InvalidLogicalLocator,
2249    /// Coordinate basis reuses an unsigned axis.
2250    #[error("source coordinate basis must use each unsigned axis exactly once")]
2251    DuplicateBasisAxis,
2252    /// Linear unit is not finite and positive.
2253    #[error("metres per source unit must be finite and positive")]
2254    InvalidLinearUnit,
2255    /// Source frame rate is not finite and positive.
2256    #[error("source frames per second must be finite and positive")]
2257    InvalidFramesPerSecond,
2258    /// Source range is non-finite or reversed.
2259    #[error("source time range endpoints must be finite with begin <= end")]
2260    InvalidTimeRange,
2261    /// A positive-presence construct row has no occurrences.
2262    #[error("source construct occurrence count must be positive")]
2263    ZeroConstructCount,
2264    /// Source clip rows do not form the canonical zero-based source prefix.
2265    #[error("source clip index {actual} is not the expected prefix index {expected}")]
2266    NonCanonicalClipIndex {
2267        /// Expected zero-based source index.
2268        expected: usize,
2269        /// Actual source index.
2270        actual: usize,
2271    },
2272    /// Nested channel rows do not form the canonical zero-based source prefix.
2273    #[error(
2274        "source channel index {actual} is not expected prefix index {expected} in clip {source_clip_index}"
2275    )]
2276    NonCanonicalChannelIndex {
2277        /// Source clip containing the invalid order.
2278        source_clip_index: usize,
2279        /// Expected zero-based channel index.
2280        expected: usize,
2281        /// Actual channel index.
2282        actual: usize,
2283    },
2284    /// Construct rows do not form the canonical deterministic prefix.
2285    #[error("source construct order {actual} is not expected prefix index {expected}")]
2286    NonCanonicalConstructOrder {
2287        /// Expected zero-based row order.
2288        expected: usize,
2289        /// Actual source order.
2290        actual: usize,
2291    },
2292    /// Resource rows do not form the canonical deterministic prefix.
2293    #[error("source resource order {actual} is not expected prefix index {expected}")]
2294    NonCanonicalResourceOrder {
2295        /// Expected zero-based row order.
2296        expected: usize,
2297        /// Actual source order.
2298        actual: usize,
2299    },
2300    /// A source clip maps outside the normalized document.
2301    #[error("normalized clip index {index} is outside document clip count {clip_count}")]
2302    NormalizedClipIndexOutOfRange {
2303        /// Invalid normalized clip index.
2304        index: usize,
2305        /// Number of normalized clips.
2306        clip_count: usize,
2307    },
2308    /// The dependency closure did not bind to these exact raw facts.
2309    #[error(transparent)]
2310    DependencyClosure(#[from] DependencyClosureError),
2311}
2312
2313#[cfg(test)]
2314mod tests {
2315    use super::*;
2316
2317    fn format_provenance() -> SourceProvenanceV1 {
2318        SourceProvenanceV1::format_defined()
2319    }
2320
2321    fn unavailable<T>() -> SourceObservationV1<T> {
2322        SourceObservationV1::unavailable(
2323            SourceUnavailableReasonV1::ParserUnavailable,
2324            None,
2325            SourceLoaderDispositionV1::Unknown,
2326        )
2327    }
2328
2329    fn exact_observed<T>(value: T) -> crate::ExactSourceTimingObservationV1<T> {
2330        crate::ExactSourceTimingObservationV1::observed(
2331            value,
2332            format_provenance(),
2333            SourceLoaderDispositionV1::Preserved,
2334        )
2335    }
2336
2337    fn clip(index: usize) -> SourceClipFactV1 {
2338        SourceClipFactV1::new(
2339            index,
2340            SourceObservationV1::proven_absent(format_provenance()),
2341            unavailable(),
2342            SourceObservationV1::proven_absent(format_provenance()),
2343            SourceObservationV1::proven_absent(format_provenance()),
2344            SourceFactSetV1::complete(Vec::new()),
2345        )
2346    }
2347
2348    fn construct(index: usize, name: String) -> SourceConstructFactV1 {
2349        SourceConstructFactV1::new(
2350            index,
2351            SourceConstructKindV1::Extension,
2352            SourceTextV1::new(name).expect("bounded name"),
2353            false,
2354            1,
2355            SourceLoaderDispositionV1::Unsupported,
2356            format_provenance(),
2357        )
2358        .expect("positive construct count")
2359    }
2360
2361    fn resource(index: usize) -> SourceResourceReferenceV1 {
2362        SourceResourceReferenceV1::new(
2363            index,
2364            SourceResourceKindV1::Image,
2365            index as u64,
2366            SourceResourceLocatorV1::Embedded,
2367            SourceLoaderDispositionV1::Preserved,
2368            format_provenance(),
2369        )
2370    }
2371
2372    fn named_channel(index: usize, name: &str) -> SourceChannelFactV1 {
2373        SourceChannelFactV1::new(
2374            index,
2375            SourceTargetV1::new(SourceTargetKindV1::Node, 0),
2376            SourceChannelPropertyV1::Other,
2377            SourceComponentMaskV1::new(true, false, false),
2378            SourceObservationV1::proven_absent(format_provenance()),
2379            SourceLoaderDispositionV1::Unsupported,
2380            format_provenance(),
2381        )
2382        .with_property_name(SourceTextV1::new(name).expect("bounded property name"))
2383    }
2384
2385    #[test]
2386    fn scalar_value_types_reject_non_finite_and_contradictory_values() {
2387        for value in [0.0, -1.0, f64::NAN, f64::INFINITY] {
2388            assert_eq!(
2389                SourceLinearUnitV1::new(value),
2390                Err(SourceFactsError::InvalidLinearUnit)
2391            );
2392            assert_eq!(
2393                SourceFramesPerSecondV1::new(value),
2394                Err(SourceFactsError::InvalidFramesPerSecond)
2395            );
2396        }
2397        assert_eq!(
2398            SourceTimeRangeV1::new(2.0, 1.0),
2399            Err(SourceFactsError::InvalidTimeRange)
2400        );
2401        assert_eq!(
2402            SourceTimeRangeV1::new(f64::NAN, 1.0),
2403            Err(SourceFactsError::InvalidTimeRange)
2404        );
2405        assert_eq!(
2406            SourceCoordinateBasisV1::new(
2407                SourceAxisV1::PositiveX,
2408                SourceAxisV1::NegativeX,
2409                SourceAxisV1::PositiveZ,
2410            ),
2411            Err(SourceFactsError::DuplicateBasisAxis)
2412        );
2413
2414        let range = SourceTimeRangeV1::new(1.0, 1.0).expect("zero duration is evidence");
2415        assert_eq!(range.begin_s(), range.end_s());
2416        let basis = SourceCoordinateBasisV1::new(
2417            SourceAxisV1::PositiveX,
2418            SourceAxisV1::PositiveY,
2419            SourceAxisV1::PositiveZ,
2420        )
2421        .expect("orthogonal basis");
2422        assert_eq!(basis.handedness(), SourceHandednessV1::Right);
2423        assert_eq!(
2424            SourceConstructFactV1::new(
2425                0,
2426                SourceConstructKindV1::Extension,
2427                SourceTextV1::new("EXT_zero").expect("bounded name"),
2428                false,
2429                0,
2430                SourceLoaderDispositionV1::Unsupported,
2431                format_provenance(),
2432            ),
2433            Err(SourceFactsError::ZeroConstructCount)
2434        );
2435    }
2436
2437    #[test]
2438    fn partial_empty_sets_never_prove_absence() {
2439        let complete = SourceFactSetV1::<SourceConstructFactV1>::complete(Vec::new());
2440        let partial = SourceFactSetV1::<SourceConstructFactV1>::partial(
2441            Vec::new(),
2442            SourceUnavailableReasonV1::ProjectionBudgetExceeded,
2443        );
2444        let unavailable = SourceFactSetV1::<SourceConstructFactV1>::unavailable(
2445            SourceUnavailableReasonV1::LoaderUnsupported,
2446        );
2447        assert!(complete.proves_absence());
2448        assert!(!partial.proves_absence());
2449        assert!(!unavailable.proves_absence());
2450    }
2451
2452    #[test]
2453    fn builder_requires_explicit_complete_coverage_to_prove_absence() {
2454        let builder = RawSourceFactsBuilderV1::new(
2455            SourceFormatV1::GltfJson,
2456            InputIdentity::from_bytes(b"untouched"),
2457        );
2458        let loaded = builder
2459            .finish(Document::default())
2460            .expect("unavailable defaults");
2461        let facts = loaded.source_facts();
2462        assert!(!facts.clips().proves_absence());
2463        assert!(!facts.constructs().proves_absence());
2464        assert!(!facts.resources().proves_absence());
2465        assert_eq!(
2466            loaded.dependency_closure().coverage().reasons(),
2467            &[
2468                crate::DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable,
2469                crate::DependencyClosureCoverageReasonV1::CaptureUnavailable,
2470            ]
2471        );
2472
2473        let mut builder = RawSourceFactsBuilderV1::new(
2474            SourceFormatV1::GltfJson,
2475            InputIdentity::from_bytes(b"exhaustive"),
2476        );
2477        builder.mark_complete(SourceFactDomainV1::Clips);
2478        builder.mark_complete(SourceFactDomainV1::Constructs);
2479        builder.mark_complete(SourceFactDomainV1::Resources);
2480        let loaded = builder
2481            .finish(Document::default())
2482            .expect("complete empty domains");
2483        let facts = loaded.source_facts();
2484        assert!(facts.clips().proves_absence());
2485        assert!(facts.constructs().proves_absence());
2486        assert!(facts.resources().proves_absence());
2487        assert_eq!(
2488            loaded.dependency_closure().coverage().reasons(),
2489            &[crate::DependencyClosureCoverageReasonV1::CaptureUnavailable]
2490        );
2491    }
2492
2493    #[test]
2494    fn loaded_source_binds_raw_gltf_addressability_to_exact_primary_and_closure() {
2495        fn inventory(
2496            primary: InputIdentity,
2497            closure: DependencyClosureV1,
2498        ) -> RawGltfAddressabilityInventoryV1 {
2499            RawGltfAddressabilityInventoryV1::new(
2500                primary,
2501                closure,
2502                crate::RawGltfAddressabilityInventoryInputV1 {
2503                    default_scene: crate::RawGltfDefaultSceneObservationV1::Absent,
2504                    scene_coverage: crate::RawGltfAddressabilityCoverageV1::Complete,
2505                    scenes: Vec::new(),
2506                    node_coverage: crate::RawGltfAddressabilityCoverageV1::Complete,
2507                    nodes: Vec::new(),
2508                    skin_coverage: crate::RawGltfAddressabilityCoverageV1::Complete,
2509                    skins: Vec::new(),
2510                    attachment_coverage: crate::RawGltfAddressabilityCoverageV1::Complete,
2511                    attachments: Vec::new(),
2512                    path_candidate_coverage: crate::RawGltfAddressabilityCoverageV1::Complete,
2513                    path_candidates: Vec::new(),
2514                },
2515            )
2516            .unwrap()
2517        }
2518
2519        let primary = InputIdentity::from_bytes(b"gltf-addressability");
2520        let source = RawSourceFactsBuilderV1::new(SourceFormatV1::GltfJson, primary.clone())
2521            .finish(Document::default())
2522            .unwrap();
2523        let exact = inventory(primary.clone(), source.dependency_closure().clone());
2524        let source = source
2525            .with_raw_gltf_addressability_inventory(exact)
2526            .expect("exact sidecar binds");
2527        assert!(source.raw_gltf_addressability_inventory().is_some());
2528
2529        let wrong_primary = InputIdentity::from_bytes(b"other");
2530        let wrong = inventory(
2531            wrong_primary.clone(),
2532            DependencyClosureV1::unavailable(wrong_primary),
2533        );
2534        assert_eq!(
2535            RawSourceFactsBuilderV1::new(SourceFormatV1::GltfJson, primary.clone())
2536                .finish(Document::default())
2537                .unwrap()
2538                .with_raw_gltf_addressability_inventory(wrong)
2539                .unwrap_err(),
2540            RawGltfAddressabilityBindingErrorV1::PrimaryIdentityMismatch
2541        );
2542
2543        let wrong_closure = DependencyClosureV1::unavailable(primary.clone());
2544        let wrong = inventory(primary.clone(), wrong_closure);
2545        assert_eq!(
2546            RawSourceFactsBuilderV1::new(SourceFormatV1::GltfJson, primary)
2547                .finish(Document::default())
2548                .unwrap()
2549                .with_raw_gltf_addressability_inventory(wrong)
2550                .unwrap_err(),
2551            RawGltfAddressabilityBindingErrorV1::DependencyClosureMismatch
2552        );
2553    }
2554
2555    #[test]
2556    fn loaded_source_accepts_a_complete_format_bound_dependency_closure() {
2557        let primary = InputIdentity::from_bytes(b"gltf");
2558        let mut facts = RawSourceFactsBuilderV1::new(SourceFormatV1::GltfJson, primary.clone());
2559        assert!(facts.push_resource(SourceResourceReferenceV1::new(
2560            0,
2561            SourceResourceKindV1::Buffer,
2562            0,
2563            SourceResourceLocatorV1::classify("buffers/a%20b.bin"),
2564            SourceLoaderDispositionV1::Preserved,
2565            format_provenance(),
2566        )));
2567        facts.mark_complete(SourceFactDomainV1::Resources);
2568
2569        let key = crate::DependencyResourceKeyV1::from_source_str(
2570            "buffers/a b.bin",
2571            crate::ResourceKeySyntaxV1::GltfUri,
2572        )
2573        .unwrap();
2574        let mut closure = crate::DependencyClosureBuilderV1::new(
2575            primary.clone(),
2576            facts.resource_coverage(),
2577            facts.resource_rows().len(),
2578        );
2579        assert!(closure.begin_reference(17, 2));
2580        assert_eq!(closure.prepare_external_key(&key).unwrap(), Some(true));
2581        closure.record_external_open_attempt(&key).unwrap();
2582        assert!(
2583            closure
2584                .push_captured_external(
2585                    0,
2586                    SourceResourceKindV1::Buffer,
2587                    0,
2588                    key,
2589                    InputIdentity::from_bytes(b"buffer"),
2590                )
2591                .unwrap()
2592        );
2593        let loaded = facts
2594            .finish_with_dependency_closure(Document::default(), closure.finish().unwrap())
2595            .unwrap();
2596        assert!(loaded.dependency_closure().coverage().is_complete());
2597        assert!(loaded.dependency_closure().identity().is_some());
2598        let document = loaded.into_document();
2599        assert!(document.clips.is_empty());
2600    }
2601
2602    #[test]
2603    fn nested_partial_channels_do_not_weaken_complete_clip_identity_coverage() {
2604        let mut builder = RawSourceFactsBuilderV1::new(
2605            SourceFormatV1::Fbx,
2606            InputIdentity::from_bytes(b"nested-partial"),
2607        );
2608        assert!(builder.push_clip(SourceClipFactV1::new(
2609            0,
2610            SourceObservationV1::proven_absent(format_provenance()),
2611            unavailable(),
2612            SourceObservationV1::proven_absent(format_provenance()),
2613            SourceObservationV1::proven_absent(format_provenance()),
2614            SourceFactSetV1::partial(
2615                vec![named_channel(0, "property")],
2616                SourceUnavailableReasonV1::ParserUnavailable,
2617            ),
2618        )));
2619        builder.mark_complete(SourceFactDomainV1::Clips);
2620        let loaded = builder
2621            .finish(Document::default())
2622            .expect("independent coverage");
2623        let facts = loaded.source_facts();
2624        assert_eq!(facts.clips().coverage(), SourceSetCoverageV1::complete());
2625        assert_eq!(
2626            facts.clips().rows()[0].channels().coverage(),
2627            SourceSetCoverageV1::partial(SourceUnavailableReasonV1::ParserUnavailable)
2628        );
2629    }
2630
2631    #[test]
2632    fn resource_locator_classification_redacts_unsafe_spelling() {
2633        let secret = "/home/example/private.bin";
2634        for classified in [
2635            SourceResourceLocatorV1::classify(secret),
2636            SourceResourceLocatorV1::classify("https://example.invalid/a.bin"),
2637            SourceResourceLocatorV1::classify("../escape.bin"),
2638            SourceResourceLocatorV1::classify("a/%2e%2e/escape.bin"),
2639            SourceResourceLocatorV1::classify("a/%2f/escape.bin"),
2640            SourceResourceLocatorV1::classify("bad%q0.bin"),
2641        ] {
2642            assert!(!format!("{classified:?}").contains(secret));
2643            assert!(!matches!(classified, SourceResourceLocatorV1::Relative(_)));
2644        }
2645        let control_bearing = "textures/TOP_SECRET\nname.png";
2646        let classified = SourceResourceLocatorV1::classify(control_bearing);
2647        assert_eq!(classified, SourceResourceLocatorV1::Malformed);
2648        assert!(!format!("{classified:?}").contains("TOP_SECRET"));
2649        assert_eq!(
2650            SourceResourceLocatorV1::retained_relative_bytes(control_bearing),
2651            0
2652        );
2653        let SourceResourceLocatorV1::Relative(relative) =
2654            SourceResourceLocatorV1::classify("textures/normal.png")
2655        else {
2656            panic!("safe relative declaration retained");
2657        };
2658        assert_eq!(relative.as_str(), "textures/normal.png");
2659        assert_eq!(
2660            SourceResourceLocatorV1::retained_relative_bytes("textures/normal.png"),
2661            "textures/normal.png".len()
2662        );
2663        assert_eq!(
2664            SourceResourceLocatorV1::retained_relative_bytes("../private.bin"),
2665            0
2666        );
2667        assert_eq!(
2668            SourceResourceLocatorV1::classify("data:image/png;base64,private"),
2669            SourceResourceLocatorV1::DataUri
2670        );
2671        assert_eq!(
2672            SourceResourceLocatorV1::classify("DATA:image/png;base64,private"),
2673            SourceResourceLocatorV1::DataUri
2674        );
2675        let oversized_data_uri = format!(
2676            "data:application/octet-stream;base64,{}",
2677            "A".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES)
2678        );
2679        assert_eq!(
2680            SourceResourceLocatorV1::classify(&oversized_data_uri),
2681            SourceResourceLocatorV1::DataUri
2682        );
2683        assert_eq!(
2684            SourceResourceLocatorV1::retained_relative_bytes(&oversized_data_uri),
2685            0
2686        );
2687        assert_eq!(
2688            SourceResourceLocatorV1::classify(r"C:\private\texture.png"),
2689            SourceResourceLocatorV1::Absolute
2690        );
2691        assert_eq!(
2692            SourceResourceLocatorV1::classify("file:///private/texture.png"),
2693            SourceResourceLocatorV1::Absolute
2694        );
2695    }
2696
2697    #[test]
2698    fn provenance_uses_only_validated_source_logical_locators() {
2699        for value in [
2700            "/home/private/input.glb",
2701            "/../animations",
2702            "https://host/path",
2703        ] {
2704            assert_eq!(
2705                SourceLogicalLocatorV1::gltf_json_pointer(value),
2706                Err(SourceFactsError::InvalidLogicalLocator)
2707            );
2708        }
2709        for value in [
2710            "/home/private/input.fbx",
2711            "fbx:/home/private",
2712            "fbx:../private",
2713        ] {
2714            assert_eq!(
2715                SourceLogicalLocatorV1::fbx_parser_path(value),
2716                Err(SourceFactsError::InvalidLogicalLocator)
2717            );
2718        }
2719
2720        let pointer = SourceLogicalLocatorV1::gltf_json_pointer("/animations/0/channels/1")
2721            .expect("generated glTF pointer");
2722        let provenance = SourceProvenanceV1::source_declared(pointer);
2723        assert_eq!(provenance.kind(), SourceProvenanceKindV1::SourceDeclared);
2724        assert_eq!(
2725            provenance.locator().map(SourceLogicalLocatorV1::as_str),
2726            Some("/animations/0/channels/1")
2727        );
2728        assert!(!format!("{provenance:?}").contains("animations"));
2729
2730        let path = SourceLogicalLocatorV1::fbx_parser_path("fbx:scene.settings.axes")
2731            .expect("generated FBX parser path");
2732        assert_eq!(
2733            SourceProvenanceV1::parser_projected(path).kind(),
2734            SourceProvenanceKindV1::ParserProjected
2735        );
2736        assert!(SourceProvenanceV1::format_defined().locator().is_none());
2737    }
2738
2739    #[test]
2740    fn loaded_source_binds_exact_identity_and_canonical_source_skeleton() {
2741        let bytes = b"same bytes parsed by the loader";
2742        let identity = InputIdentity::from_bytes(bytes);
2743        let mut document = Document::default();
2744        document.source.path = Some("/home/example/private/input.glb".into());
2745        let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Glb, identity.clone());
2746        builder.set_linear_unit(SourceObservationV1::observed(
2747            SourceLinearUnitV1::new(1.0).expect("metres"),
2748            format_provenance(),
2749            SourceLoaderDispositionV1::Preserved,
2750        ));
2751        let loaded = builder.finish(document).expect("facts bind");
2752        let source_skeleton_ptr = &loaded.document().assets.source_skeleton as *const _;
2753        let facts = loaded.source_facts();
2754        assert_eq!(facts.contract_id(), RAW_SOURCE_FACTS_V1_ID);
2755        assert_eq!(facts.format(), SourceFormatV1::Glb);
2756        assert_eq!(facts.primary_identity(), &identity);
2757        assert_eq!(facts.primary_identity().bytes(), bytes.len() as u64);
2758        assert!(loaded.exact_source_timing().is_none());
2759        assert!(std::ptr::eq(
2760            facts.source_skeleton() as *const _,
2761            source_skeleton_ptr
2762        ));
2763        assert_eq!(loaded.dependency_closure().primary_input(), &identity);
2764        assert!(matches!(
2765            loaded.dependency_closure().coverage(),
2766            crate::DependencyClosureCoverageV1::Unavailable { .. }
2767        ));
2768        assert!(loaded.dependency_closure().identity().is_none());
2769        assert!(!format!("{loaded:?}").contains("/home/example/private"));
2770        let document = loaded.into_document();
2771        assert!(document.clips.is_empty());
2772    }
2773
2774    #[test]
2775    fn exact_source_timing_attachment_is_format_neutral_and_rejects_mismatched_clip_domains() {
2776        let loaded = || {
2777            let mut builder = RawSourceFactsBuilderV1::new(
2778                SourceFormatV1::Glb,
2779                InputIdentity::from_bytes(b"generic-exact-source-timing"),
2780            );
2781            assert!(builder.push_clip(clip(0)));
2782            builder.mark_complete(SourceFactDomainV1::Clips);
2783            builder.finish(Document::default()).expect("facts bind")
2784        };
2785
2786        let exact = |coverage, clips: Vec<crate::ExactSourceClipTimingV1>| {
2787            ExactSourceTimingV1::new(
2788                exact_observed(crate::ExactSourceTimeBasisV1::new(1_000).unwrap()),
2789                exact_observed(crate::SourceTimelineModeV1::Fps24),
2790                exact_observed(crate::SourceTimelineModeV1::Fps24),
2791                crate::ExactSourceTimingObservationV1::proven_absent(format_provenance()),
2792                exact_observed(crate::ExactSourceFramePeriodV1::new(1).unwrap()),
2793                crate::ExactSourceTimingObservationV1::proven_absent(format_provenance()),
2794                exact_observed(crate::SourceTimeDisplayProtocolV1::Default),
2795                coverage,
2796                clips,
2797            )
2798            .unwrap()
2799        };
2800        let one_clip = || {
2801            vec![crate::ExactSourceClipTimingV1::new(
2802                0,
2803                exact_observed(
2804                    crate::ExactSourceClipTimeRangeV1::new(
2805                        crate::ExactSourceRangeSelectionV1::Primary,
2806                        0,
2807                        1,
2808                    )
2809                    .unwrap(),
2810                ),
2811            )]
2812        };
2813
2814        let attached = loaded()
2815            .with_exact_source_timing(exact(SourceSetCoverageV1::complete(), one_clip()))
2816            .expect("generic exact timing attaches to a GLB source");
2817        assert!(attached.exact_source_timing().is_some());
2818
2819        assert!(matches!(
2820            loaded().with_exact_source_timing(exact(SourceSetCoverageV1::complete(), Vec::new())),
2821            Err(ExactSourceTimingContractError::ClipCountMismatch {
2822                exact: 0,
2823                source_count: 1,
2824            })
2825        ));
2826        assert!(matches!(
2827            loaded().with_exact_source_timing(exact(
2828                SourceSetCoverageV1::partial(SourceUnavailableReasonV1::ParserUnavailable),
2829                one_clip(),
2830            )),
2831            Err(ExactSourceTimingContractError::ClipCoverageMismatch)
2832        ));
2833    }
2834
2835    #[test]
2836    fn clip_limit_retains_n_then_marks_n_plus_one_partial() {
2837        let mut builder =
2838            RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, InputIdentity::from_bytes(b"fbx"));
2839        for index in 0..RAW_SOURCE_V1_MAX_CLIPS {
2840            assert!(builder.push_clip(clip(index)));
2841        }
2842        assert!(!builder.push_clip(clip(RAW_SOURCE_V1_MAX_CLIPS)));
2843        assert!(!builder.push_clip(clip(RAW_SOURCE_V1_MAX_CLIPS + 1)));
2844        let loaded = builder.finish(Document::default()).expect("bounded facts");
2845        let facts = loaded.source_facts();
2846        assert_eq!(facts.clips().rows().len(), RAW_SOURCE_V1_MAX_CLIPS);
2847        assert_eq!(
2848            facts.clips().coverage(),
2849            SourceSetCoverageV1::partial(SourceUnavailableReasonV1::ProjectionBudgetExceeded)
2850        );
2851        assert_eq!(facts.work().inspected_rows(), RAW_SOURCE_V1_MAX_CLIPS + 1);
2852    }
2853
2854    #[test]
2855    fn resource_limit_retains_n_then_marks_n_plus_one_partial() {
2856        let mut builder = RawSourceFactsBuilderV1::new(
2857            SourceFormatV1::GltfJson,
2858            InputIdentity::from_bytes(b"gltf"),
2859        );
2860        for index in 0..RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES {
2861            assert!(builder.push_resource(resource(index)));
2862        }
2863        assert!(!builder.push_resource(resource(RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES)));
2864        assert!(!builder.push_resource(resource(RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES + 1)));
2865        let loaded = builder.finish(Document::default()).expect("bounded facts");
2866        let facts = loaded.source_facts();
2867        assert_eq!(
2868            facts.resources().rows().len(),
2869            RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES
2870        );
2871        assert_eq!(
2872            facts.resources().coverage().state(),
2873            SourceSetCoverageStateV1::Partial
2874        );
2875        assert_eq!(
2876            facts.work().inspected_rows(),
2877            RAW_SOURCE_V1_MAX_RESOURCE_REFERENCES + 1
2878        );
2879    }
2880
2881    #[test]
2882    fn observed_clip_names_count_toward_the_aggregate_text_limit() {
2883        let rows_at_limit = RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES / RAW_SOURCE_V1_MAX_TEXT_BYTES;
2884        let mut named_clips = RawSourceFactsBuilderV1::new(
2885            SourceFormatV1::GltfJson,
2886            InputIdentity::from_bytes(b"named-clips"),
2887        );
2888        for index in 0..rows_at_limit {
2889            let source_name = SourceObservationV1::observed(
2890                SourceTextV1::new("n".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES)).expect("bounded name"),
2891                format_provenance(),
2892                SourceLoaderDispositionV1::Preserved,
2893            );
2894            assert!(named_clips.push_clip(SourceClipFactV1::new(
2895                index,
2896                source_name,
2897                SourceObservationV1::proven_absent(format_provenance()),
2898                SourceObservationV1::proven_absent(format_provenance()),
2899                SourceObservationV1::proven_absent(format_provenance()),
2900                SourceFactSetV1::complete(Vec::new()),
2901            )));
2902        }
2903        let overflow_name = SourceObservationV1::observed(
2904            SourceTextV1::new("n".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES)).expect("bounded name"),
2905            format_provenance(),
2906            SourceLoaderDispositionV1::Preserved,
2907        );
2908        assert!(!named_clips.push_clip(SourceClipFactV1::new(
2909            rows_at_limit,
2910            overflow_name,
2911            SourceObservationV1::proven_absent(format_provenance()),
2912            SourceObservationV1::proven_absent(format_provenance()),
2913            SourceObservationV1::proven_absent(format_provenance()),
2914            SourceFactSetV1::complete(Vec::new()),
2915        )));
2916        let loaded = named_clips
2917            .finish(Document::default())
2918            .expect("bounded named clips");
2919        assert_eq!(
2920            loaded.source_facts().work().retained_text_bytes(),
2921            RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES
2922        );
2923        assert_eq!(
2924            loaded.source_facts().clips().coverage().state(),
2925            SourceSetCoverageStateV1::Partial
2926        );
2927    }
2928
2929    #[test]
2930    fn aggregate_text_limit_retains_nested_channel_prefix() {
2931        let mut builder = RawSourceFactsBuilderV1::new(
2932            SourceFormatV1::GltfJson,
2933            InputIdentity::from_bytes(b"channel-prefix"),
2934        );
2935        let full_rows = RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES / RAW_SOURCE_V1_MAX_TEXT_BYTES;
2936        for _ in 0..full_rows - 1 {
2937            assert!(builder.push_construct(construct(
2938                builder.facts.constructs.rows.len(),
2939                "x".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES)
2940            )));
2941        }
2942        assert!(builder.push_construct(construct(
2943            builder.facts.constructs.rows.len(),
2944            "x".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES - 12)
2945        )));
2946        assert_eq!(builder.remaining_text_bytes(), 12);
2947
2948        let channels = SourceFactSetV1::complete(vec![
2949            named_channel(0, "aaaa"),
2950            named_channel(1, "bbbb"),
2951            named_channel(2, "cccc"),
2952        ]);
2953        assert!(builder.push_clip(SourceClipFactV1::new(
2954            0,
2955            SourceObservationV1::observed(
2956                SourceTextV1::new("name").expect("bounded name"),
2957                format_provenance(),
2958                SourceLoaderDispositionV1::Preserved,
2959            ),
2960            SourceObservationV1::proven_absent(format_provenance()),
2961            SourceObservationV1::proven_absent(format_provenance()),
2962            SourceObservationV1::proven_absent(format_provenance()),
2963            channels,
2964        )));
2965
2966        let loaded = builder.finish(Document::default()).expect("bounded prefix");
2967        let facts = loaded.source_facts();
2968        assert_eq!(
2969            facts.work().retained_text_bytes(),
2970            RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES
2971        );
2972        assert_eq!(facts.clips().rows().len(), 1);
2973        assert_eq!(facts.clips().rows()[0].channels().rows().len(), 2);
2974        assert_eq!(
2975            facts.clips().rows()[0].channels().coverage().state(),
2976            SourceSetCoverageStateV1::Partial
2977        );
2978        assert_eq!(
2979            facts.clips().coverage().state(),
2980            SourceSetCoverageStateV1::Partial
2981        );
2982    }
2983
2984    #[test]
2985    fn total_observation_limit_retains_exact_prefix_and_work_count() {
2986        let mut builder = RawSourceFactsBuilderV1::new(
2987            SourceFormatV1::GltfJson,
2988            InputIdentity::from_bytes(b"gltf"),
2989        );
2990        for index in 0..=RAW_SOURCE_V1_MAX_OBSERVATIONS {
2991            let retained = builder.push_construct(construct(index, format!("e{index}")));
2992            assert_eq!(retained, index < RAW_SOURCE_V1_MAX_OBSERVATIONS);
2993        }
2994        assert!(!builder.push_construct(construct(
2995            RAW_SOURCE_V1_MAX_OBSERVATIONS + 1,
2996            "must-not-resume".to_string()
2997        )));
2998        let loaded = builder.finish(Document::default()).expect("bounded facts");
2999        let facts = loaded.source_facts();
3000        assert_eq!(
3001            facts.constructs().rows().len(),
3002            RAW_SOURCE_V1_MAX_OBSERVATIONS
3003        );
3004        assert_eq!(
3005            facts.constructs().coverage(),
3006            SourceSetCoverageV1::partial(SourceUnavailableReasonV1::ProjectionBudgetExceeded)
3007        );
3008        assert_eq!(
3009            facts.work().inspected_rows(),
3010            RAW_SOURCE_V1_MAX_OBSERVATIONS + 1
3011        );
3012        assert_eq!(facts.work().retained_rows(), RAW_SOURCE_V1_MAX_OBSERVATIONS);
3013    }
3014
3015    #[test]
3016    fn unavailable_domain_discards_prefix_and_updates_retained_work() {
3017        let mut builder = RawSourceFactsBuilderV1::new(
3018            SourceFormatV1::GltfJson,
3019            InputIdentity::from_bytes(b"discarded-prefix"),
3020        );
3021        assert!(builder.push_construct(construct(0, "retained-name".to_string())));
3022        assert_eq!(
3023            builder.remaining_observation_rows(),
3024            RAW_SOURCE_V1_MAX_OBSERVATIONS - 1
3025        );
3026        assert_eq!(
3027            builder.remaining_text_bytes(),
3028            RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES - 13
3029        );
3030
3031        builder.mark_unavailable(
3032            SourceFactDomainV1::Constructs,
3033            SourceUnavailableReasonV1::ParserUnavailable,
3034        );
3035        builder.mark_partial(
3036            SourceFactDomainV1::Constructs,
3037            SourceUnavailableReasonV1::ProjectionBudgetExceeded,
3038        );
3039        let loaded = builder
3040            .finish(Document::default())
3041            .expect("unavailable set remains valid");
3042        let facts = loaded.source_facts();
3043        assert!(facts.constructs().rows().is_empty());
3044        assert_eq!(
3045            facts.constructs().coverage(),
3046            SourceSetCoverageV1::unavailable(SourceUnavailableReasonV1::ParserUnavailable)
3047        );
3048        assert_eq!(facts.work().retained_rows(), 0);
3049        assert_eq!(facts.work().retained_text_bytes(), 0);
3050        assert_eq!(facts.work().inspected_rows(), 1);
3051    }
3052
3053    #[test]
3054    fn preallocation_budget_stop_counts_terminal_row() {
3055        let mut builder = RawSourceFactsBuilderV1::new(
3056            SourceFormatV1::GltfJson,
3057            InputIdentity::from_bytes(b"preallocation-stop"),
3058        );
3059        assert!(builder.push_construct(construct(0, "first".to_string())));
3060        builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
3061        assert!(!builder.push_construct(construct(1, "must-not-resume".to_string())));
3062        let loaded = builder.finish(Document::default()).expect("partial prefix");
3063        let facts = loaded.source_facts();
3064        assert_eq!(facts.work().inspected_rows(), 2);
3065        assert_eq!(facts.work().retained_rows(), 1);
3066        assert_eq!(facts.constructs().rows()[0].name().as_str(), "first");
3067        assert_eq!(
3068            facts.constructs().coverage(),
3069            SourceSetCoverageV1::partial(SourceUnavailableReasonV1::ProjectionBudgetExceeded)
3070        );
3071    }
3072
3073    #[test]
3074    fn text_and_traversal_limits_are_exact_and_coverage_qualified() {
3075        assert!(SourceTextV1::new("x".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES)).is_ok());
3076        assert_eq!(
3077            SourceTextV1::new("x".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES + 1)),
3078            Err(SourceFactsError::TextTooLong {
3079                bytes: RAW_SOURCE_V1_MAX_TEXT_BYTES + 1,
3080                limit: RAW_SOURCE_V1_MAX_TEXT_BYTES,
3081            })
3082        );
3083
3084        let mut builder = RawSourceFactsBuilderV1::new(
3085            SourceFormatV1::GltfJson,
3086            InputIdentity::from_bytes(b"gltf"),
3087        );
3088        let rows_at_limit = RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES / RAW_SOURCE_V1_MAX_TEXT_BYTES;
3089        for index in 0..rows_at_limit {
3090            assert!(
3091                builder.push_construct(construct(index, "x".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES)))
3092            );
3093        }
3094        assert!(!builder.push_construct(construct(
3095            rows_at_limit,
3096            "x".repeat(RAW_SOURCE_V1_MAX_TEXT_BYTES)
3097        )));
3098        assert!(!builder.push_construct(construct(rows_at_limit + 1, "short".to_string())));
3099        assert!(
3100            !builder.set_linear_unit(SourceObservationV1::observed(
3101                SourceLinearUnitV1::new(1.0).expect("metres"),
3102                SourceProvenanceV1::source_declared(
3103                    SourceLogicalLocatorV1::gltf_json_pointer("/animations")
3104                        .expect("generated logical locator"),
3105                ),
3106                SourceLoaderDispositionV1::Preserved,
3107            ))
3108        );
3109        assert!(builder.observe_traversal_depth(
3110            SourceFactDomainV1::Resources,
3111            RAW_SOURCE_V1_MAX_TRAVERSAL_DEPTH
3112        ));
3113        assert!(!builder.observe_traversal_depth(
3114            SourceFactDomainV1::Resources,
3115            RAW_SOURCE_V1_MAX_TRAVERSAL_DEPTH + 1
3116        ));
3117        assert!(!builder.push_resource(resource(0)));
3118        let loaded = builder.finish(Document::default()).expect("bounded facts");
3119        let facts = loaded.source_facts();
3120        assert_eq!(
3121            facts.work().retained_text_bytes(),
3122            RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES
3123        );
3124        assert!(matches!(
3125            facts.linear_unit().state(),
3126            SourceObservationStateV1::Unavailable(
3127                SourceUnavailableReasonV1::ProjectionBudgetExceeded
3128            )
3129        ));
3130        assert_eq!(
3131            facts.work().max_traversal_depth(),
3132            RAW_SOURCE_V1_MAX_TRAVERSAL_DEPTH + 1
3133        );
3134        assert_eq!(
3135            facts.resources().coverage().state(),
3136            SourceSetCoverageStateV1::Partial
3137        );
3138    }
3139
3140    #[test]
3141    fn finish_rejects_stale_normalized_clip_mappings_and_noncanonical_order() {
3142        let observed_index = |index| {
3143            SourceObservationV1::observed(
3144                index,
3145                format_provenance(),
3146                SourceLoaderDispositionV1::Preserved,
3147            )
3148        };
3149        let make = |source_index, normalized_index| {
3150            SourceClipFactV1::new(
3151                source_index,
3152                SourceObservationV1::proven_absent(format_provenance()),
3153                observed_index(normalized_index),
3154                SourceObservationV1::proven_absent(format_provenance()),
3155                SourceObservationV1::proven_absent(format_provenance()),
3156                SourceFactSetV1::complete(Vec::new()),
3157            )
3158        };
3159
3160        let mut stale =
3161            RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, InputIdentity::from_bytes(b"fbx"));
3162        assert!(stale.push_clip(make(0, 0)));
3163        assert!(matches!(
3164            stale.finish(Document::default()),
3165            Err(SourceFactsError::NormalizedClipIndexOutOfRange { .. })
3166        ));
3167
3168        let mut unordered =
3169            RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, InputIdentity::from_bytes(b"fbx"));
3170        assert!(unordered.push_clip(clip(1)));
3171        assert!(unordered.push_clip(clip(0)));
3172        assert!(matches!(
3173            unordered.finish(Document::default()),
3174            Err(SourceFactsError::NonCanonicalClipIndex { .. })
3175        ));
3176
3177        let mut channel_gap =
3178            RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, InputIdentity::from_bytes(b"fbx"));
3179        assert!(channel_gap.push_clip(SourceClipFactV1::new(
3180            0,
3181            SourceObservationV1::proven_absent(format_provenance()),
3182            unavailable(),
3183            SourceObservationV1::proven_absent(format_provenance()),
3184            SourceObservationV1::proven_absent(format_provenance()),
3185            SourceFactSetV1::partial(
3186                vec![named_channel(1, "gap")],
3187                SourceUnavailableReasonV1::ParserUnavailable,
3188            ),
3189        )));
3190        assert!(matches!(
3191            channel_gap.finish(Document::default()),
3192            Err(SourceFactsError::NonCanonicalChannelIndex { .. })
3193        ));
3194
3195        let mut construct_gap = RawSourceFactsBuilderV1::new(
3196            SourceFormatV1::GltfJson,
3197            InputIdentity::from_bytes(b"gltf"),
3198        );
3199        assert!(construct_gap.push_construct(construct(1, "gap".to_string())));
3200        assert!(matches!(
3201            construct_gap.finish(Document::default()),
3202            Err(SourceFactsError::NonCanonicalConstructOrder { .. })
3203        ));
3204
3205        let mut resource_gap = RawSourceFactsBuilderV1::new(
3206            SourceFormatV1::GltfJson,
3207            InputIdentity::from_bytes(b"gltf"),
3208        );
3209        assert!(resource_gap.push_resource(resource(1)));
3210        assert!(matches!(
3211            resource_gap.finish(Document::default()),
3212            Err(SourceFactsError::NonCanonicalResourceOrder { .. })
3213        ));
3214    }
3215}