1use std::collections::{HashMap, HashSet};
8
9use snomed_core::sctid::SctId;
10use snomed_owl::{Axiom, ObjectPropertyExpression};
11
12use crate::skipped::SkippedConstruct;
13use crate::stated_profile::{self, Attribute as RawAttribute, StatedProfile};
14use crate::{classify, normalize, Classification};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Attribute {
23 pub group: u32,
24 pub type_id: SctId,
25 pub destination_id: SctId,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct NecessaryNormalForm {
33 pub is_a: Vec<SctId>,
34 pub attributes: Vec<Attribute>,
35}
36
37#[derive(Debug, Clone)]
42pub struct NecessaryNormalFormReport {
43 pub forms: HashMap<SctId, NecessaryNormalForm>,
44 pub skipped: Vec<SkippedConstruct>,
45}
46
47pub fn necessary_normal_form(axioms: &[Axiom]) -> NecessaryNormalFormReport {
53 let classification_report = classify(axioms);
54 let classification = classification_report.classification;
55
56 let (profiles, mut skipped) = stated_profile::extract_stated_profiles(axioms);
57 skipped.extend(classification_report.skipped.iter().copied());
58 dedup_unordered(&mut skipped);
59
60 let role_ancestors = role_ancestor_closure(axioms);
61
62 let mut concepts: HashSet<SctId> = classification.concepts().collect();
63 concepts.extend(profiles.keys().copied());
64
65 let proximal: HashMap<SctId, Vec<SctId>> = concepts
66 .iter()
67 .map(|&c| (c, proximal_parents(c, &classification)))
68 .collect();
69
70 let mut ctx = Context {
71 profiles: &profiles,
72 classification: &classification,
73 role_ancestors: &role_ancestors,
74 proximal: &proximal,
75 cache: HashMap::new(),
76 in_progress: HashSet::new(),
77 chains: None,
78 };
79
80 let mut forms = HashMap::new();
85 for &c in &concepts {
86 let candidates = groups_for(c, &mut ctx);
87 let is_a = proximal.get(&c).cloned().unwrap_or_default();
88 forms.insert(c, finalize(is_a, candidates));
89 }
90
91 let chains = property_chains(axioms);
97 if !chains.is_empty() {
98 let graphs = NodeGraphs::build(&chains, &forms);
99 let affected: Vec<SctId> = forms
100 .iter()
101 .filter(|(_, form)| {
102 form.attributes.iter().any(|attribute| {
103 chains.iter().any(|chain| {
104 chain.source == attribute.type_id
105 || role_ancestors
106 .get(&attribute.type_id)
107 .is_some_and(|a| a.contains(&chain.source))
108 })
109 })
110 })
111 .map(|(&c, _)| c)
112 .collect();
113
114 ctx.chains = Some((&chains, &graphs));
115 for c in affected {
116 ctx.cache.remove(&c);
120 let candidates = groups_for(c, &mut ctx);
121 let is_a = proximal.get(&c).cloned().unwrap_or_default();
122 forms.insert(c, finalize(is_a, candidates));
123 }
124 }
125
126 NecessaryNormalFormReport { forms, skipped }
127}
128
129fn dedup_unordered<T: PartialEq + Copy>(items: &mut Vec<T>) {
130 let mut unique = Vec::with_capacity(items.len());
131 for item in items.drain(..) {
132 if !unique.contains(&item) {
133 unique.push(item);
134 }
135 }
136 *items = unique;
137}
138
139fn proximal_parents(c: SctId, classification: &Classification) -> Vec<SctId> {
149 let all: Vec<SctId> = classification.subsumers(c).collect();
150 all.iter()
151 .filter(|&&p| {
152 !all.iter().any(|&q| {
153 if q == p || !classification.is_subsumed_by(q, p) {
154 return false;
155 }
156 !classification.is_subsumed_by(p, q) || q < p
160 })
161 })
162 .copied()
163 .collect()
164}
165
166fn role_ancestor_closure(axioms: &[Axiom]) -> HashMap<SctId, HashSet<SctId>> {
170 let tbox = normalize::normalize(axioms);
171 let mut direct: HashMap<SctId, Vec<SctId>> = HashMap::new();
172 for (sub, sup) in &tbox.role_hierarchy {
173 if let (crate::types::RoleId::Named(s), crate::types::RoleId::Named(t)) = (sub, sup) {
174 direct.entry(*s).or_default().push(*t);
175 }
176 }
177
178 let mut closure: HashMap<SctId, HashSet<SctId>> = HashMap::new();
179 for &role in direct.keys() {
180 let mut seen = HashSet::from([role]);
181 let mut queue = vec![role];
182 while let Some(r) = queue.pop() {
183 for &next in direct.get(&r).map(Vec::as_slice).unwrap_or(&[]) {
184 if seen.insert(next) {
185 queue.push(next);
186 }
187 }
188 }
189 closure.insert(role, seen);
190 }
191 closure
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
201struct PropertyChain {
202 source: SctId,
203 destination: SctId,
204 inferred: SctId,
205}
206
207fn property_chains(axioms: &[Axiom]) -> Vec<PropertyChain> {
214 let mut chains = Vec::new();
215 for axiom in axioms {
216 match axiom {
217 Axiom::SubObjectPropertyOf {
218 sub: ObjectPropertyExpression::Chain(ids),
219 sup,
220 } if ids.len() == 2 => chains.push(PropertyChain {
221 source: ids[0],
222 destination: ids[1],
223 inferred: *sup,
224 }),
225 Axiom::TransitiveObjectProperty(r) => chains.push(PropertyChain {
226 source: *r,
227 destination: *r,
228 inferred: *r,
229 }),
230 _ => {}
231 }
232 }
233 chains.sort_by_key(|c| (c.source, c.destination, c.inferred));
234 chains.dedup();
235 chains
236}
237
238#[derive(Debug, Default)]
246struct NodeGraphs {
247 edges: HashMap<(SctId, SctId), Vec<SctId>>,
248}
249
250impl NodeGraphs {
251 fn build(chains: &[PropertyChain], forms: &HashMap<SctId, NecessaryNormalForm>) -> Self {
252 let traversable: HashSet<SctId> = chains.iter().map(|c| c.destination).collect();
253 let mut edges: HashMap<(SctId, SctId), Vec<SctId>> = HashMap::new();
254 for (&concept, form) in forms {
255 for attribute in &form.attributes {
256 if traversable.contains(&attribute.type_id) {
257 edges
258 .entry((concept, attribute.type_id))
259 .or_default()
260 .push(attribute.destination_id);
261 }
262 }
263 }
264 for targets in edges.values_mut() {
265 targets.sort_unstable();
266 targets.dedup();
267 }
268 NodeGraphs { edges }
269 }
270
271 fn chain_closure(
276 &self,
277 from: SctId,
278 property: SctId,
279 classification: &Classification,
280 ) -> HashSet<SctId> {
281 let mut reached = HashSet::from([from]);
282 let mut queue = vec![from];
283 while let Some(node) = queue.pop() {
284 for &next in self
285 .edges
286 .get(&(node, property))
287 .map(Vec::as_slice)
288 .unwrap_or(&[])
289 {
290 if reached.insert(next) {
291 queue.push(next);
292 }
293 }
294 }
295 let ancestors: Vec<SctId> = reached
296 .iter()
297 .flat_map(|&node| classification.subsumers(node))
298 .collect();
299 reached.extend(ancestors);
300 reached
301 }
302}
303
304struct Context<'a> {
305 profiles: &'a HashMap<SctId, StatedProfile>,
306 classification: &'a Classification,
307 role_ancestors: &'a HashMap<SctId, HashSet<SctId>>,
308 proximal: &'a HashMap<SctId, Vec<SctId>>,
309 cache: HashMap<SctId, Vec<GroupCandidate>>,
310 in_progress: HashSet<SctId>,
311 chains: Option<(&'a [PropertyChain], &'a NodeGraphs)>,
315}
316
317#[derive(Debug, Clone)]
318struct GroupCandidate {
319 group0: bool,
322 fragments: Vec<RawAttribute>,
323}
324
325fn groups_for(c: SctId, ctx: &mut Context<'_>) -> Vec<GroupCandidate> {
332 if let Some(cached) = ctx.cache.get(&c) {
333 return cached.clone();
334 }
335 if !ctx.in_progress.insert(c) {
336 return Vec::new();
337 }
338
339 let mut candidates: Vec<GroupCandidate> = Vec::new();
340
341 if let Some(profile) = ctx.profiles.get(&c) {
342 for &attr in &profile.ungrouped {
343 insert_candidate(
344 &mut candidates,
345 GroupCandidate {
346 group0: true,
347 fragments: vec![attr],
348 },
349 ctx.role_ancestors,
350 ctx.classification,
351 ctx.chains,
352 );
353 }
354 for group in profile.groups.clone() {
355 insert_candidate(
356 &mut candidates,
357 GroupCandidate {
358 group0: false,
359 fragments: group,
360 },
361 ctx.role_ancestors,
362 ctx.classification,
363 ctx.chains,
364 );
365 }
366 }
367
368 let parents = ctx.proximal.get(&c).cloned().unwrap_or_default();
369 for parent in parents {
370 for inherited in groups_for(parent, ctx) {
371 insert_candidate(
372 &mut candidates,
373 inherited,
374 ctx.role_ancestors,
375 ctx.classification,
376 ctx.chains,
377 );
378 }
379 }
380
381 ctx.in_progress.remove(&c);
382 ctx.cache.insert(c, candidates.clone());
383 candidates
384}
385
386fn insert_candidate(
390 candidates: &mut Vec<GroupCandidate>,
391 new: GroupCandidate,
392 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
393 classification: &Classification,
394 chains: Option<(&[PropertyChain], &NodeGraphs)>,
395) {
396 if candidates
397 .iter()
398 .any(|g| group_is_same_or_stronger(g, &new, role_ancestors, classification, chains))
399 {
400 return;
401 }
402 candidates
403 .retain(|g| !group_is_same_or_stronger(&new, g, role_ancestors, classification, chains));
404 candidates.push(new);
405}
406
407fn group_is_same_or_stronger(
411 candidate: &GroupCandidate,
412 other: &GroupCandidate,
413 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
414 classification: &Classification,
415 chains: Option<(&[PropertyChain], &NodeGraphs)>,
416) -> bool {
417 other.fragments.iter().all(|&weaker| {
418 candidate.fragments.iter().any(|&stronger| {
419 fragment_is_same_or_stronger(stronger, weaker, role_ancestors, classification, chains)
420 })
421 })
422}
423
424fn fragment_is_same_or_stronger(
441 stronger: RawAttribute,
442 weaker: RawAttribute,
443 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
444 classification: &Classification,
445 chains: Option<(&[PropertyChain], &NodeGraphs)>,
446) -> bool {
447 let (u, d) = stronger;
448 let (r, c) = weaker;
449 let role_closure = |role: SctId, ancestor: SctId| {
450 role == ancestor
451 || role_ancestors
452 .get(&role)
453 .is_some_and(|ancestors| ancestors.contains(&ancestor))
454 };
455
456 if role_closure(u, r) && (d == c || classification.is_subsumed_by(d, c)) {
458 return true;
459 }
460
461 let Some((chains, graphs)) = chains else {
463 return false;
464 };
465 chains
466 .iter()
467 .filter(|chain| chain.inferred == r && role_closure(u, chain.source))
468 .any(|chain| {
469 graphs
470 .chain_closure(d, chain.destination, classification)
471 .contains(&c)
472 })
473}
474
475fn finalize(mut is_a: Vec<SctId>, candidates: Vec<GroupCandidate>) -> NecessaryNormalForm {
476 is_a.sort();
477 is_a.dedup();
478
479 let mut ungrouped: Vec<RawAttribute> = Vec::new();
480 let mut numbered_groups: Vec<Vec<RawAttribute>> = Vec::new();
481 for candidate in candidates {
482 if candidate.group0 {
483 ungrouped.extend(candidate.fragments);
484 } else {
485 numbered_groups.push(candidate.fragments);
486 }
487 }
488 ungrouped.sort();
489
490 for group in &mut numbered_groups {
492 group.sort();
493 }
494 numbered_groups.sort();
495
496 let mut attributes: Vec<Attribute> = ungrouped
497 .into_iter()
498 .map(|(type_id, destination_id)| Attribute {
499 group: 0,
500 type_id,
501 destination_id,
502 })
503 .collect();
504 for (index, group) in numbered_groups.into_iter().enumerate() {
505 let group_number = (index + 1) as u32;
506 attributes.extend(
507 group
508 .into_iter()
509 .map(|(type_id, destination_id)| Attribute {
510 group: group_number,
511 type_id,
512 destination_id,
513 }),
514 );
515 }
516
517 NecessaryNormalForm { is_a, attributes }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use snomed_core::sctid::ComponentType;
524
525 fn id(item: u64) -> SctId {
528 SctId::compose(item, ComponentType::Concept, None).unwrap()
529 }
530
531 fn ax(s: &str) -> Axiom {
532 snomed_owl::parse(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
533 }
534
535 fn axioms(strs: &[String]) -> Vec<Axiom> {
536 strs.iter().map(|s| ax(s)).collect()
537 }
538
539 #[test]
540 fn proximal_parents_exclude_transitively_redundant_ancestors() {
541 let a = id(2001);
542 let b = id(2002);
543 let c = id(2003);
544 let input = axioms(&[
545 format!("SubClassOf(:{a} :{b})"),
546 format!("SubClassOf(:{b} :{c})"),
547 ]);
548 let report = necessary_normal_form(&input);
549
550 let nnf_a = &report.forms[&a];
553 assert_eq!(nnf_a.is_a, vec![b]);
554 let nnf_b = &report.forms[&b];
555 assert_eq!(nnf_b.is_a, vec![c]);
556 }
557
558 #[test]
559 fn own_specific_attribute_makes_inherited_general_one_redundant() {
560 let parent = id(2010);
565 let child = id(2011);
566 let site = id(2012);
567 let general = id(2013);
568 let specific = id(2014);
569 let input = axioms(&[
570 format!("SubClassOf(:{specific} :{general})"),
571 format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{site} :{general}))"),
572 format!("SubClassOf(:{child} :{parent})"),
573 format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{site} :{specific}))"),
574 ]);
575 let report = necessary_normal_form(&input);
576
577 let nnf_child = &report.forms[&child];
578 assert_eq!(
579 nnf_child.attributes,
580 vec![Attribute {
581 group: 0,
582 type_id: site,
583 destination_id: specific
584 }]
585 );
586 }
587
588 #[test]
589 fn role_hierarchy_makes_general_attribute_type_redundant() {
590 let parent = id(2020);
595 let child = id(2021);
596 let generic_attr = id(2022);
597 let specific_attr = id(2023);
598 let value = id(2024);
599 let input = axioms(&[
600 format!("SubObjectPropertyOf(:{specific_attr} :{generic_attr})"),
601 format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{generic_attr} :{value}))"),
602 format!("SubClassOf(:{child} :{parent})"),
603 format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{specific_attr} :{value}))"),
604 ]);
605 let report = necessary_normal_form(&input);
606
607 let nnf_child = &report.forms[&child];
608 assert_eq!(
609 nnf_child.attributes,
610 vec![Attribute {
611 group: 0,
612 type_id: specific_attr,
613 destination_id: value
614 }]
615 );
616 }
617
618 #[test]
619 fn role_group_is_reconstructed_from_the_owl_encoding() {
620 let concept = id(2030);
623 let parent = id(2031);
624 let finding_site = id(2032);
625 let site_value = id(2033);
626 let morphology = id(2034);
627 let morphology_value = id(2035);
628 let input = axioms(&[format!(
629 "EquivalentClasses(:{concept} ObjectIntersectionOf(:{parent} \
630 ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
631 ObjectSomeValuesFrom(:{finding_site} :{site_value}) \
632 ObjectSomeValuesFrom(:{morphology} :{morphology_value})))))"
633 )]);
634 let report = necessary_normal_form(&input);
635
636 let nnf = &report.forms[&concept];
637 assert_eq!(nnf.is_a, vec![parent]);
638 let mut attrs = nnf.attributes.clone();
639 attrs.sort_by_key(|a| a.type_id);
640 assert_eq!(
641 attrs,
642 vec![
643 Attribute {
644 group: 1,
645 type_id: finding_site,
646 destination_id: site_value
647 },
648 Attribute {
649 group: 1,
650 type_id: morphology,
651 destination_id: morphology_value
652 },
653 ]
654 );
655 }
656
657 #[test]
658 fn ungrouped_attribute_stays_group_zero() {
659 let concept = id(2040);
662 let attr = id(2041);
663 let value = id(2042);
664 let input = axioms(&[format!(
665 "SubClassOf(:{concept} ObjectSomeValuesFrom(:{attr} :{value}))"
666 )]);
667 let report = necessary_normal_form(&input);
668
669 let nnf = &report.forms[&concept];
670 assert_eq!(
671 nnf.attributes,
672 vec![Attribute {
673 group: 0,
674 type_id: attr,
675 destination_id: value
676 }]
677 );
678 }
679
680 #[test]
681 fn group_redundant_across_two_whole_groups_is_eliminated() {
682 let parent = id(2050);
687 let child = id(2051);
688 let site_attr = id(2052);
689 let general_site = id(2053);
690 let specific_site = id(2054);
691 let extra_attr = id(2055);
692 let extra_value = id(2056);
693 let input = axioms(&[
694 format!("SubClassOf(:{specific_site} :{general_site})"),
695 format!(
696 "SubClassOf(:{parent} ObjectSomeValuesFrom(:609096000 \
697 ObjectSomeValuesFrom(:{site_attr} :{general_site})))"
698 ),
699 format!("SubClassOf(:{child} :{parent})"),
700 format!(
701 "SubClassOf(:{child} ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
702 ObjectSomeValuesFrom(:{site_attr} :{specific_site}) \
703 ObjectSomeValuesFrom(:{extra_attr} :{extra_value}))))"
704 ),
705 ]);
706 let report = necessary_normal_form(&input);
707
708 let nnf_child = &report.forms[&child];
709 let mut attrs = nnf_child.attributes.clone();
710 attrs.sort_by_key(|a| a.type_id);
711 assert_eq!(
712 attrs,
713 vec![
714 Attribute {
715 group: 1,
716 type_id: site_attr,
717 destination_id: specific_site
718 },
719 Attribute {
720 group: 1,
721 type_id: extra_attr,
722 destination_id: extra_value
723 },
724 ]
725 );
726 }
727
728 #[test]
729 fn unmodeled_attribute_shape_is_reported_not_silently_dropped() {
730 let concept = id(2060);
732 let attr = id(2061);
733 let value_attr = id(2062);
734 let input = axioms(&[format!(
735 "SubClassOf(:{concept} ObjectSomeValuesFrom(:609096000 \
736 DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
737 )]);
738 let report = necessary_normal_form(&input);
739
740 let nnf = &report.forms[&concept];
741 assert!(nnf.attributes.is_empty());
742 assert!(report
743 .skipped
744 .contains(&SkippedConstruct::UnmodeledAttributeShape { concept }));
745 let _ = attr; }
747
748 #[test]
749 fn gci_contributes_only_via_subsumption_never_a_direct_profile() {
750 let a = id(2070);
754 let b = id(2071);
755 let c = id(2072);
756 let x = id(2073);
757 let attr = id(2074);
758 let value = id(2075);
759 let input = axioms(&[
760 format!("SubClassOf(ObjectIntersectionOf(:{a} :{b}) :{c})"),
761 format!("SubClassOf(:{c} ObjectSomeValuesFrom(:{attr} :{value}))"),
762 format!("EquivalentClasses(:{x} ObjectIntersectionOf(:{a} :{b}))"),
763 ]);
764 let report = necessary_normal_form(&input);
765
766 let nnf_x = &report.forms[&x];
767 assert!(nnf_x.is_a.contains(&c));
768 assert_eq!(
769 nnf_x.attributes,
770 vec![Attribute {
771 group: 0,
772 type_id: attr,
773 destination_id: value
774 }]
775 );
776 }
777
778 #[test]
779 fn equivalent_parents_do_not_eliminate_each_other() {
780 let a = id(1090);
785 let b = id(1091);
786 let c = id(1092);
787 assert!(b < c, "the test relies on b sorting before c");
788 let report = necessary_normal_form(&[
789 ax(&format!("EquivalentClasses(:{b} :{c})")),
790 ax(&format!("SubClassOf(:{a} :{b})")),
791 ]);
792 assert_eq!(report.forms[&a].is_a, vec![b]);
793 }
794
795 #[test]
796 fn a_property_chain_makes_a_more_general_attribute_redundant() {
797 let finding_site = id(1200);
804 let part_of = id(1201);
805 let hand = id(1202);
806 let upper_limb = id(1203);
807 let disorder = id(1204);
808 let hand_disorder = id(1205);
809
810 let axioms = vec![
811 ax(&format!(
812 "SubObjectPropertyOf(ObjectPropertyChain(:{finding_site} :{part_of}) :{finding_site})"
813 )),
814 ax(&format!(
817 "SubClassOf(:{hand} ObjectSomeValuesFrom(:{part_of} :{upper_limb}))"
818 )),
819 ax(&format!(
820 "EquivalentClasses(:{hand_disorder} ObjectIntersectionOf(:{disorder} \
821 ObjectSomeValuesFrom(:{finding_site} :{hand}) \
822 ObjectSomeValuesFrom(:{finding_site} :{upper_limb})))"
823 )),
824 ];
825
826 let report = necessary_normal_form(&axioms);
827 let form = &report.forms[&hand_disorder];
828 let sites: Vec<SctId> = form
829 .attributes
830 .iter()
831 .filter(|a| a.type_id == finding_site)
832 .map(|a| a.destination_id)
833 .collect();
834 assert_eq!(
835 sites,
836 vec![hand],
837 "the Upper limb site is implied by the Hand site through the chain"
838 );
839 }
840
841 #[test]
842 fn a_transitive_property_makes_a_reachable_attribute_redundant() {
843 let part_of = id(1210);
848 let hand = id(1211);
849 let upper_limb = id(1212);
850 let structure = id(1213);
851 let thing = id(1214);
852
853 let axioms = vec![
854 ax(&format!("TransitiveObjectProperty(:{part_of})")),
855 ax(&format!(
856 "SubClassOf(:{hand} ObjectSomeValuesFrom(:{part_of} :{upper_limb}))"
857 )),
858 ax(&format!(
859 "EquivalentClasses(:{thing} ObjectIntersectionOf(:{structure} \
860 ObjectSomeValuesFrom(:{part_of} :{hand}) \
861 ObjectSomeValuesFrom(:{part_of} :{upper_limb})))"
862 )),
863 ];
864
865 let report = necessary_normal_form(&axioms);
866 let parts: Vec<SctId> = report.forms[&thing]
867 .attributes
868 .iter()
869 .filter(|a| a.type_id == part_of)
870 .map(|a| a.destination_id)
871 .collect();
872 assert_eq!(parts, vec![hand]);
873 }
874
875 #[test]
876 fn a_chain_does_not_eliminate_an_unreachable_attribute() {
877 let finding_site = id(1220);
881 let part_of = id(1221);
882 let hand = id(1222);
883 let foot = id(1223);
884 let disorder = id(1224);
885 let odd_disorder = id(1225);
886
887 let axioms = vec![
888 ax(&format!(
889 "SubObjectPropertyOf(ObjectPropertyChain(:{finding_site} :{part_of}) :{finding_site})"
890 )),
891 ax(&format!(
892 "EquivalentClasses(:{odd_disorder} ObjectIntersectionOf(:{disorder} \
893 ObjectSomeValuesFrom(:{finding_site} :{hand}) \
894 ObjectSomeValuesFrom(:{finding_site} :{foot})))"
895 )),
896 ];
897
898 let mut sites: Vec<SctId> = necessary_normal_form(&axioms).forms[&odd_disorder]
899 .attributes
900 .iter()
901 .filter(|a| a.type_id == finding_site)
902 .map(|a| a.destination_id)
903 .collect();
904 sites.sort();
905 let mut expected = vec![hand, foot];
906 expected.sort();
907 assert_eq!(sites, expected, "neither site implies the other");
908 }
909}