1use std::cmp::Reverse;
4
5use crate::{
6 ContentStructure, Error, ErrorKind, MAX_SEQUENCE_EXCEPTIONS, RepresentationId, ResourceId,
7 Result, uri::normalize_uri,
8};
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct Confidence(u16);
13
14impl Confidence {
15 pub const CERTAIN: Self = Self(10_000);
17
18 pub fn from_basis_points(value: u16) -> Result<Self> {
24 if value > 10_000 {
25 return Err(Error::new(
26 ErrorKind::InvalidArgument,
27 "confidence must not exceed 10,000 basis points",
28 ));
29 }
30 Ok(Self(value))
31 }
32
33 #[must_use]
35 pub const fn basis_points(self) -> u16 {
36 self.0
37 }
38}
39
40#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
42#[non_exhaustive]
43pub enum EvidenceKind {
44 KnownLocatorAvailable,
46 ExactFingerprintMatch,
48 FullHashMatch,
50 PartialFingerprintMatch,
52 FileSizeMatch,
54 FileNameMatch,
56 RelativePathSimilarity,
58 MediaRootRelation,
60 MediaRootUnmapped,
62 MediaRootUnavailable,
64 FingerprintMismatch,
66 ConflictingCandidate,
68 DiscoveryError,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct ResolutionEvidence {
75 kind: EvidenceKind,
76 detail: Option<String>,
77}
78
79impl ResolutionEvidence {
80 #[must_use]
82 pub fn new(kind: EvidenceKind, detail: Option<String>) -> Self {
83 Self { kind, detail }
84 }
85
86 #[must_use]
88 pub const fn kind(&self) -> EvidenceKind {
89 self.kind
90 }
91
92 #[must_use]
94 pub fn detail(&self) -> Option<&str> {
95 self.detail.as_deref()
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct ResolutionCandidate {
102 uri: String,
103 confidence: Confidence,
104 evidence: Vec<ResolutionEvidence>,
105}
106
107impl ResolutionCandidate {
108 pub fn new(
115 uri: impl Into<String>,
116 confidence: Confidence,
117 evidence: Vec<ResolutionEvidence>,
118 ) -> Result<Self> {
119 let uri = normalize_uri(uri, "resolution candidate")?;
120 if evidence.is_empty() {
121 return Err(Error::new(
122 ErrorKind::InvalidArgument,
123 "resolution candidate must contain evidence",
124 ));
125 }
126 Ok(Self {
127 uri,
128 confidence,
129 evidence,
130 })
131 }
132
133 #[must_use]
135 pub fn uri(&self) -> &str {
136 &self.uri
137 }
138
139 #[must_use]
141 pub const fn confidence(&self) -> Confidence {
142 self.confidence
143 }
144
145 #[must_use]
147 pub fn evidence(&self) -> &[ResolutionEvidence] {
148 &self.evidence
149 }
150}
151
152#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
154#[non_exhaustive]
155pub enum ResourceResolutionState {
156 OnlineAtKnownLocator,
158 ResolvedExact,
160 ResolvedProbable,
162 Offline,
164 Ambiguous,
166 Error,
168}
169
170#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct ResourceResolution {
173 resource_id: ResourceId,
174 state: ResourceResolutionState,
175 candidates: Vec<ResolutionCandidate>,
176 evidence: Vec<ResolutionEvidence>,
177 missing_frames: Vec<i64>,
178}
179
180impl ResourceResolution {
181 pub fn new(
188 resource_id: ResourceId,
189 state: ResourceResolutionState,
190 mut candidates: Vec<ResolutionCandidate>,
191 evidence: Vec<ResolutionEvidence>,
192 ) -> Result<Self> {
193 candidates.sort_by(|left, right| {
194 (Reverse(left.confidence), left.uri.as_str())
195 .cmp(&(Reverse(right.confidence), right.uri.as_str()))
196 });
197 let valid_count = match state {
198 ResourceResolutionState::OnlineAtKnownLocator
199 | ResourceResolutionState::ResolvedExact
200 | ResourceResolutionState::ResolvedProbable => candidates.len() == 1,
201 ResourceResolutionState::Offline | ResourceResolutionState::Error => {
202 candidates.is_empty()
203 }
204 ResourceResolutionState::Ambiguous => candidates.len() >= 2,
205 };
206 if !valid_count {
207 return Err(Error::new(
208 ErrorKind::InvalidArgument,
209 "candidate count is inconsistent with resource resolution state",
210 ));
211 }
212 Ok(Self {
213 resource_id,
214 state,
215 candidates,
216 evidence,
217 missing_frames: Vec::new(),
218 })
219 }
220
221 pub fn with_missing_frames(mut self, mut missing_frames: Vec<i64>) -> Result<Self> {
228 if missing_frames.len() > MAX_SEQUENCE_EXCEPTIONS {
229 return Err(Error::new(
230 ErrorKind::InvalidArgument,
231 format!("resolution has more than {MAX_SEQUENCE_EXCEPTIONS} missing frames"),
232 ));
233 }
234 missing_frames.sort_unstable();
235 missing_frames.dedup();
236 self.missing_frames = missing_frames;
237 Ok(self)
238 }
239
240 #[must_use]
242 pub const fn resource_id(&self) -> ResourceId {
243 self.resource_id
244 }
245
246 #[must_use]
248 pub const fn state(&self) -> ResourceResolutionState {
249 self.state
250 }
251
252 #[must_use]
254 pub fn candidates(&self) -> &[ResolutionCandidate] {
255 &self.candidates
256 }
257
258 #[must_use]
260 pub fn evidence(&self) -> &[ResolutionEvidence] {
261 &self.evidence
262 }
263
264 #[must_use]
266 pub fn missing_frames(&self) -> &[i64] {
267 &self.missing_frames
268 }
269}
270
271#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
273#[non_exhaustive]
274pub enum RepresentationAvailability {
275 Online,
277 Partial,
279 Offline,
281 Ambiguous,
283 Error,
285}
286
287#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
289#[non_exhaustive]
290pub enum AvailabilityIssueKind {
291 OfflineResource,
293 AmbiguousResource,
295 ResourceError,
297 MissingFrames,
299}
300
301#[derive(Clone, Debug, Eq, PartialEq)]
303pub struct AvailabilityIssue {
304 resource_id: ResourceId,
305 required: bool,
306 kind: AvailabilityIssueKind,
307 frames: Vec<i64>,
308}
309
310impl AvailabilityIssue {
311 #[must_use]
313 pub const fn resource_id(&self) -> ResourceId {
314 self.resource_id
315 }
316
317 #[must_use]
319 pub const fn is_required(&self) -> bool {
320 self.required
321 }
322
323 #[must_use]
325 pub const fn kind(&self) -> AvailabilityIssueKind {
326 self.kind
327 }
328
329 #[must_use]
331 pub fn frames(&self) -> &[i64] {
332 &self.frames
333 }
334}
335
336#[derive(Clone, Debug, Eq, PartialEq)]
338pub struct RepresentationResolution {
339 representation_id: RepresentationId,
340 availability: RepresentationAvailability,
341 resources: Vec<ResourceResolution>,
342 issues: Vec<AvailabilityIssue>,
343}
344
345impl RepresentationResolution {
346 pub fn aggregate(
357 representation_id: RepresentationId,
358 structure: &ContentStructure,
359 resources: Vec<ResourceResolution>,
360 ) -> Result<Self> {
361 let ordered = order_resource_resolutions(structure, resources)?;
362 let (availability, issues) = inspect_availability(structure, &ordered);
363 Ok(Self {
364 representation_id,
365 availability,
366 resources: ordered,
367 issues,
368 })
369 }
370
371 #[must_use]
373 pub const fn representation_id(&self) -> RepresentationId {
374 self.representation_id
375 }
376
377 #[must_use]
379 pub const fn availability(&self) -> RepresentationAvailability {
380 self.availability
381 }
382
383 #[must_use]
385 pub fn resources(&self) -> &[ResourceResolution] {
386 &self.resources
387 }
388
389 #[must_use]
391 pub fn issues(&self) -> &[AvailabilityIssue] {
392 &self.issues
393 }
394}
395
396#[derive(Default)]
397struct AvailabilityCounts {
398 online_required: usize,
399 offline_required: usize,
400 ambiguous_required: bool,
401 error_required: bool,
402 missing_frames: bool,
403}
404
405impl AvailabilityCounts {
406 const fn availability(&self) -> RepresentationAvailability {
407 if self.error_required {
408 RepresentationAvailability::Error
409 } else if self.ambiguous_required {
410 RepresentationAvailability::Ambiguous
411 } else if self.online_required == 0 {
412 RepresentationAvailability::Offline
413 } else if self.offline_required > 0 || self.missing_frames {
414 RepresentationAvailability::Partial
415 } else {
416 RepresentationAvailability::Online
417 }
418 }
419}
420
421fn order_resource_resolutions(
422 structure: &ContentStructure,
423 resources: Vec<ResourceResolution>,
424) -> Result<Vec<ResourceResolution>> {
425 let expected = structure.resource_ids();
426 if resources.len() != expected.len() {
427 return Err(resource_set_error());
428 }
429 let mut by_id = std::collections::BTreeMap::new();
430 for resource in resources {
431 if by_id.insert(resource.resource_id(), resource).is_some() {
432 return Err(Error::new(
433 ErrorKind::InvalidArgument,
434 "resource resolution is duplicated",
435 ));
436 }
437 }
438 expected
439 .into_iter()
440 .map(|resource_id| by_id.remove(&resource_id).ok_or_else(resource_set_error))
441 .collect()
442}
443
444fn resource_set_error() -> Error {
445 Error::new(
446 ErrorKind::InvalidArgument,
447 "resource resolutions do not match the content structure",
448 )
449}
450
451fn inspect_availability(
452 structure: &ContentStructure,
453 resources: &[ResourceResolution],
454) -> (RepresentationAvailability, Vec<AvailabilityIssue>) {
455 let mut counts = AvailabilityCounts::default();
456 let mut issues = Vec::new();
457 for resource in resources {
458 inspect_resource(structure, resource, &mut counts, &mut issues);
459 }
460 if let Some(descriptor) = structure.image_sequence_descriptor() {
461 let mut missing_frames = descriptor.known_missing_frames().to_vec();
462 if let Some(resource) = resources
463 .iter()
464 .find(|resource| resource.resource_id() == descriptor.resource_id())
465 {
466 missing_frames.extend_from_slice(resource.missing_frames());
467 }
468 missing_frames.sort_unstable();
469 missing_frames.dedup();
470 if !missing_frames.is_empty() {
471 counts.missing_frames = true;
472 issues.push(AvailabilityIssue {
473 resource_id: descriptor.resource_id(),
474 required: true,
475 kind: AvailabilityIssueKind::MissingFrames,
476 frames: missing_frames,
477 });
478 }
479 }
480 (counts.availability(), issues)
481}
482
483fn inspect_resource(
484 structure: &ContentStructure,
485 resource: &ResourceResolution,
486 counts: &mut AvailabilityCounts,
487 issues: &mut Vec<AvailabilityIssue>,
488) {
489 let resource_id = resource.resource_id();
490 let required = resource_is_required(structure, resource_id);
491 let issue_kind = match resource.state() {
492 ResourceResolutionState::OnlineAtKnownLocator
493 | ResourceResolutionState::ResolvedExact
494 | ResourceResolutionState::ResolvedProbable => {
495 counts.online_required += usize::from(required);
496 None
497 }
498 ResourceResolutionState::Offline => {
499 counts.offline_required += usize::from(required);
500 Some(AvailabilityIssueKind::OfflineResource)
501 }
502 ResourceResolutionState::Ambiguous => {
503 counts.ambiguous_required |= required;
504 Some(AvailabilityIssueKind::AmbiguousResource)
505 }
506 ResourceResolutionState::Error => {
507 counts.error_required |= required;
508 Some(AvailabilityIssueKind::ResourceError)
509 }
510 };
511 if let Some(kind) = issue_kind {
512 issues.push(AvailabilityIssue {
513 resource_id,
514 required,
515 kind,
516 frames: Vec::new(),
517 });
518 }
519}
520
521fn resource_is_required(structure: &ContentStructure, resource_id: ResourceId) -> bool {
522 structure.members().is_none_or(|members| {
523 members
524 .iter()
525 .find(|member| member.resource_id() == resource_id)
526 .is_some_and(crate::ResourceMember::is_required)
527 })
528}
529
530#[cfg(test)]
531mod tests {
532 use crate::{
533 FrameRange, ImageSequenceDescriptor, ImageSequencePattern, RationalRate, ResourceMember,
534 ResourceRole,
535 };
536 use proptest::prelude::*;
537
538 use super::*;
539
540 fn candidate(uri: &str, confidence: u16) -> ResolutionCandidate {
541 ResolutionCandidate::new(
542 uri,
543 Confidence::from_basis_points(confidence).expect("test confidence is valid"),
544 vec![ResolutionEvidence::new(EvidenceKind::FileSizeMatch, None)],
545 )
546 .expect("test candidate is valid")
547 }
548
549 fn resource_result(
550 resource_id: ResourceId,
551 state: ResourceResolutionState,
552 ) -> ResourceResolution {
553 let candidates = match state {
554 ResourceResolutionState::OnlineAtKnownLocator
555 | ResourceResolutionState::ResolvedExact
556 | ResourceResolutionState::ResolvedProbable => {
557 vec![candidate(&format!("file:///{resource_id}"), 10_000)]
558 }
559 ResourceResolutionState::Ambiguous => vec![
560 candidate(&format!("file:///a/{resource_id}"), 9_000),
561 candidate(&format!("file:///b/{resource_id}"), 9_000),
562 ],
563 ResourceResolutionState::Offline | ResourceResolutionState::Error => Vec::new(),
564 };
565 ResourceResolution::new(resource_id, state, candidates, Vec::new())
566 .expect("test resource resolution is valid")
567 }
568
569 #[test]
570 fn candidates_are_sorted_deterministically() {
571 let resolution = ResourceResolution::new(
572 ResourceId::new(),
573 ResourceResolutionState::Ambiguous,
574 vec![
575 candidate("file:///z", 8_000),
576 candidate("file:///b", 9_000),
577 candidate("file:///a", 9_000),
578 ],
579 Vec::new(),
580 )
581 .expect("ambiguous result has enough candidates");
582
583 let uris: Vec<_> = resolution
584 .candidates()
585 .iter()
586 .map(ResolutionCandidate::uri)
587 .collect();
588 assert_eq!(uris, ["file:///a", "file:///b", "file:///z"]);
589 }
590
591 #[test]
592 fn ambiguity_cannot_silently_select_one_candidate() {
593 let error = ResourceResolution::new(
594 ResourceId::new(),
595 ResourceResolutionState::Ambiguous,
596 vec![candidate("file:///only", 10_000)],
597 Vec::new(),
598 )
599 .expect_err("one candidate cannot be ambiguous");
600 assert_eq!(error.kind(), ErrorKind::InvalidArgument);
601 }
602
603 #[test]
604 fn optional_package_members_do_not_reduce_availability() {
605 let essence = ResourceId::new();
606 let thumbnail = ResourceId::new();
607 let structure = ContentStructure::package(vec![
608 ResourceMember::new(
609 essence,
610 ResourceRole::new("org.postproject:essence").expect("valid role"),
611 true,
612 ),
613 ResourceMember::new(
614 thumbnail,
615 ResourceRole::new("org.postproject:thumbnail").expect("valid role"),
616 false,
617 ),
618 ])
619 .expect("valid package");
620
621 let resolution = RepresentationResolution::aggregate(
622 RepresentationId::new(),
623 &structure,
624 vec![
625 resource_result(thumbnail, ResourceResolutionState::Offline),
626 resource_result(essence, ResourceResolutionState::OnlineAtKnownLocator),
627 ],
628 )
629 .expect("matching results aggregate");
630
631 assert_eq!(
632 resolution.availability(),
633 RepresentationAvailability::Online
634 );
635 assert_eq!(resolution.resources()[0].resource_id(), essence);
636 assert_eq!(resolution.issues().len(), 1);
637 assert!(!resolution.issues()[0].is_required());
638 }
639
640 #[test]
641 fn some_missing_required_members_make_a_representation_partial() {
642 let first = ResourceId::new();
643 let second = ResourceId::new();
644 let role = ResourceRole::new("org.postproject:essence").expect("valid role");
645 let structure = ContentStructure::ordered_parts(vec![
646 ResourceMember::new(first, role.clone(), true),
647 ResourceMember::new(second, role, true),
648 ])
649 .expect("valid ordered parts");
650
651 let resolution = RepresentationResolution::aggregate(
652 RepresentationId::new(),
653 &structure,
654 vec![
655 resource_result(first, ResourceResolutionState::ResolvedExact),
656 resource_result(second, ResourceResolutionState::Offline),
657 ],
658 )
659 .expect("matching results aggregate");
660
661 assert_eq!(
662 resolution.availability(),
663 RepresentationAvailability::Partial
664 );
665 assert_eq!(
666 resolution.issues()[0].kind(),
667 AvailabilityIssueKind::OfflineResource
668 );
669 assert!(resolution.issues()[0].is_required());
670 }
671
672 #[test]
673 fn known_sequence_gaps_are_partial_with_frame_diagnostics() {
674 let resource_id = ResourceId::new();
675 let descriptor = ImageSequenceDescriptor::new(
676 resource_id,
677 ImageSequencePattern::new("shot.", ".exr", 4).expect("valid pattern"),
678 FrameRange::new(1001, 1004, 1).expect("valid frame range"),
679 RationalRate::new(24, 1).expect("valid rate"),
680 vec![1002, 1003],
681 )
682 .expect("valid sequence");
683 let structure = ContentStructure::image_sequence(descriptor);
684
685 let resolution = RepresentationResolution::aggregate(
686 RepresentationId::new(),
687 &structure,
688 vec![resource_result(
689 resource_id,
690 ResourceResolutionState::OnlineAtKnownLocator,
691 )],
692 )
693 .expect("matching results aggregate");
694
695 assert_eq!(
696 resolution.availability(),
697 RepresentationAvailability::Partial
698 );
699 assert_eq!(
700 resolution.issues()[0].kind(),
701 AvailabilityIssueKind::MissingFrames
702 );
703 assert_eq!(resolution.issues()[0].frames(), [1002, 1003]);
704 }
705
706 #[test]
707 fn observed_sequence_gaps_merge_with_recorded_exceptions() {
708 let resource_id = ResourceId::new();
709 let descriptor = ImageSequenceDescriptor::new(
710 resource_id,
711 ImageSequencePattern::new("shot.", ".exr", 4).expect("valid pattern"),
712 FrameRange::new(1001, 1004, 1).expect("valid frame range"),
713 RationalRate::new(24, 1).expect("valid rate"),
714 vec![1002],
715 )
716 .expect("valid sequence");
717 let structure = ContentStructure::image_sequence(descriptor);
718 let resource = resource_result(resource_id, ResourceResolutionState::OnlineAtKnownLocator)
719 .with_missing_frames(vec![1004, 1003, 1002])
720 .expect("bounded frame diagnostics");
721
722 let resolution = RepresentationResolution::aggregate(
723 RepresentationId::new(),
724 &structure,
725 vec![resource],
726 )
727 .expect("matching results aggregate");
728
729 assert_eq!(
730 resolution.availability(),
731 RepresentationAvailability::Partial
732 );
733 assert_eq!(resolution.issues()[0].frames(), [1002, 1003, 1004]);
734 }
735
736 #[test]
737 fn aggregate_rejects_the_wrong_resource_set() {
738 let expected = ResourceId::new();
739 let unexpected = ResourceId::new();
740 let error = RepresentationResolution::aggregate(
741 RepresentationId::new(),
742 &ContentStructure::single_resource(expected),
743 vec![resource_result(
744 unexpected,
745 ResourceResolutionState::Offline,
746 )],
747 )
748 .expect_err("unrelated resource must be rejected");
749
750 assert_eq!(error.kind(), ErrorKind::InvalidArgument);
751 }
752
753 proptest! {
754 #[test]
755 fn ordering_is_invariant_under_input_reversal(
756 left_score in 0_u16..=10_000,
757 right_score in 0_u16..=10_000,
758 ) {
759 let id = ResourceId::from_bytes([4; 16]);
760 let forward = ResourceResolution::new(
761 id,
762 ResourceResolutionState::Ambiguous,
763 vec![candidate("file:///a", left_score), candidate("file:///b", right_score)],
764 Vec::new(),
765 ).expect("valid resolution");
766 let reverse = ResourceResolution::new(
767 id,
768 ResourceResolutionState::Ambiguous,
769 vec![candidate("file:///b", right_score), candidate("file:///a", left_score)],
770 Vec::new(),
771 ).expect("valid resolution");
772
773 prop_assert_eq!(forward, reverse);
774 }
775 }
776}