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