Skip to main content

postproject_core/
resolution.rs

1//! Explainable and deterministic media-resolution results.
2
3use std::cmp::Reverse;
4
5use crate::{
6    ContentStructure, Error, ErrorKind, MAX_SEQUENCE_EXCEPTIONS, RepresentationId, ResourceId,
7    Result, uri::normalize_uri,
8};
9
10/// A deterministic confidence value in basis points from 0 through 10,000.
11#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct Confidence(u16);
13
14impl Confidence {
15    /// Certain confidence used only for verified identity evidence.
16    pub const CERTAIN: Self = Self(10_000);
17
18    /// Creates a confidence value, rejecting values above 100%.
19    ///
20    /// # Errors
21    ///
22    /// Returns [`ErrorKind::InvalidArgument`] when `value` exceeds 10,000.
23    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    /// Returns the confidence in basis points.
34    #[must_use]
35    pub const fn basis_points(self) -> u16 {
36        self.0
37    }
38}
39
40/// A machine-inspectable reason supporting or opposing a resolution candidate.
41#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
42#[non_exhaustive]
43pub enum EvidenceKind {
44    /// A persisted locator is currently available.
45    KnownLocatorAvailable,
46    /// The complete stored fingerprint matches.
47    ExactFingerprintMatch,
48    /// A cryptographic full-file digest matches.
49    FullHashMatch,
50    /// A sampled or otherwise partial fingerprint matches.
51    PartialFingerprintMatch,
52    /// The byte size matches.
53    FileSizeMatch,
54    /// The final path component matches.
55    FileNameMatch,
56    /// The path relative to a media root is similar.
57    RelativePathSimilarity,
58    /// The candidate is contained in a configured media root.
59    MediaRootRelation,
60    /// A logical media root has no mapping on this machine.
61    MediaRootUnmapped,
62    /// A mapped media root cannot currently be searched.
63    MediaRootUnavailable,
64    /// Present content does not match its stored fingerprint evidence.
65    FingerprintMismatch,
66    /// Another candidate has equivalent credible evidence.
67    ConflictingCandidate,
68    /// Candidate discovery or verification could not complete safely.
69    DiscoveryError,
70}
71
72/// Structured evidence with optional human-readable context.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct ResolutionEvidence {
75    kind: EvidenceKind,
76    detail: Option<String>,
77}
78
79impl ResolutionEvidence {
80    /// Creates a structured evidence item.
81    #[must_use]
82    pub fn new(kind: EvidenceKind, detail: Option<String>) -> Self {
83        Self { kind, detail }
84    }
85
86    /// Returns the machine-readable evidence kind.
87    #[must_use]
88    pub const fn kind(&self) -> EvidenceKind {
89        self.kind
90    }
91
92    /// Returns optional diagnostic context.
93    #[must_use]
94    pub fn detail(&self) -> Option<&str> {
95        self.detail.as_deref()
96    }
97}
98
99/// A possible resource locator considered by the resolver.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct ResolutionCandidate {
102    uri: String,
103    confidence: Confidence,
104    evidence: Vec<ResolutionEvidence>,
105}
106
107impl ResolutionCandidate {
108    /// Creates a candidate with an absolute URI and at least one evidence item.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`ErrorKind::InvalidArgument`] when `uri` is invalid or relative,
113    /// or when `evidence` is empty.
114    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    /// Returns the candidate URI.
134    #[must_use]
135    pub fn uri(&self) -> &str {
136        &self.uri
137    }
138
139    /// Returns the deterministic confidence value.
140    #[must_use]
141    pub const fn confidence(&self) -> Confidence {
142        self.confidence
143    }
144
145    /// Returns the inspectable supporting evidence.
146    #[must_use]
147    pub fn evidence(&self) -> &[ResolutionEvidence] {
148        &self.evidence
149    }
150}
151
152/// The outcome of resolving one storage resource.
153#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
154#[non_exhaustive]
155pub enum ResourceResolutionState {
156    /// A persisted locator is currently online.
157    OnlineAtKnownLocator,
158    /// One candidate has exact identity evidence.
159    ResolvedExact,
160    /// One candidate is credible but lacks exact verification.
161    ResolvedProbable,
162    /// No credible candidate was found.
163    Offline,
164    /// Multiple candidates require an explicit decision.
165    Ambiguous,
166    /// Candidate discovery or verification could not complete safely.
167    Error,
168}
169
170/// An explainable result for one resource, independent of representation shape.
171#[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    /// Creates a resource result and sorts candidates deterministically.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`ErrorKind::InvalidArgument`] when the candidate count is
186    /// inconsistent with `state`.
187    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    /// Adds observed missing image-sequence frames in canonical order.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ErrorKind::InvalidArgument`] when the diagnostic exceeds the
226    /// bounded sequence-exception limit.
227    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    /// Returns the resolved resource identity.
241    #[must_use]
242    pub const fn resource_id(&self) -> ResourceId {
243        self.resource_id
244    }
245
246    /// Returns the resource outcome.
247    #[must_use]
248    pub const fn state(&self) -> ResourceResolutionState {
249        self.state
250    }
251
252    /// Returns candidates in deterministic best-first order.
253    #[must_use]
254    pub fn candidates(&self) -> &[ResolutionCandidate] {
255        &self.candidates
256    }
257
258    /// Returns resource-wide evidence and diagnostics.
259    #[must_use]
260    pub fn evidence(&self) -> &[ResolutionEvidence] {
261        &self.evidence
262    }
263
264    /// Returns sorted frames observed absent while resolving this resource.
265    #[must_use]
266    pub fn missing_frames(&self) -> &[i64] {
267        &self.missing_frames
268    }
269}
270
271/// Aggregated availability of a complete representation.
272#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
273#[non_exhaustive]
274pub enum RepresentationAvailability {
275    /// Every required resource and frame is resolvable.
276    Online,
277    /// Some required content is resolvable and some is unavailable.
278    Partial,
279    /// No required content is resolvable.
280    Offline,
281    /// A required resource has multiple plausible candidates.
282    Ambiguous,
283    /// Required resource resolution could not complete safely.
284    Error,
285}
286
287/// The machine-inspectable category of a representation availability issue.
288#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
289#[non_exhaustive]
290pub enum AvailabilityIssueKind {
291    /// A resource has no credible online candidate.
292    OfflineResource,
293    /// A resource has multiple plausible candidates.
294    AmbiguousResource,
295    /// Resolution of a resource failed safely.
296    ResourceError,
297    /// Known frames are absent from an image sequence.
298    MissingFrames,
299}
300
301/// A resource- or frame-specific availability diagnostic.
302#[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    /// Returns the affected resource or compact sequence resource.
312    #[must_use]
313    pub const fn resource_id(&self) -> ResourceId {
314        self.resource_id
315    }
316
317    /// Returns whether this content is required for a complete representation.
318    #[must_use]
319    pub const fn is_required(&self) -> bool {
320        self.required
321    }
322
323    /// Returns the diagnostic category.
324    #[must_use]
325    pub const fn kind(&self) -> AvailabilityIssueKind {
326        self.kind
327    }
328
329    /// Returns sorted missing frames for [`AvailabilityIssueKind::MissingFrames`].
330    #[must_use]
331    pub fn frames(&self) -> &[i64] {
332        &self.frames
333    }
334}
335
336/// Representation-level availability with ordered per-resource detail.
337#[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    /// Aggregates resource results according to content-structure requiredness.
347    ///
348    /// Optional package members produce diagnostics but do not reduce aggregate
349    /// availability. Known missing sequence frames make an otherwise online
350    /// sequence partial.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`ErrorKind::InvalidArgument`] unless the resource results match
355    /// the structure's resources exactly and without duplicates.
356    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    /// Returns the representation whose availability was aggregated.
372    #[must_use]
373    pub const fn representation_id(&self) -> RepresentationId {
374        self.representation_id
375    }
376
377    /// Returns aggregate representation availability.
378    #[must_use]
379    pub const fn availability(&self) -> RepresentationAvailability {
380        self.availability
381    }
382
383    /// Returns results in content-structure resource order.
384    #[must_use]
385    pub fn resources(&self) -> &[ResourceResolution] {
386        &self.resources
387    }
388
389    /// Returns resource- and frame-specific diagnostics.
390    #[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}