1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
//! Clinical hypotheses as a refinable poset.
//!
//! A `Hyp` represents a set of clinical hypotheses at some level of refinement.
//! Hypotheses are ordered by refinement: a hypothesis h1 refines h2 (h1 ≤ h2) if h1 is more specific.
//!
//! Per [SPEC.md § 2] (DEF-PS-01, INV-PS-02):
//! Hypotheses are formalized as sets of resolved atoms from external ontologies (SNOMED CT, RxNorm, LOINC, ICD-11).
//! Atoms replace the v0.1.0 `&'static str` placeholder, enabling real ontology binding and audit trail support.
use crate::ontology::Atom;
use std::cmp::Ordering;
use std::collections::HashSet;
/// A clinical hypothesis: a refinable set of resolved atoms.
///
/// Hypotheses are ordered by refinement: `h1 ≤ h2` means h1 is more specific (refines) h2.
/// This ordering forms a partially ordered set (poset) where:
/// - `Unknown` is the top element (least specific).
/// - Each concrete hypothesis carries resolved `Atom` payloads from external ontologies.
///
/// Implements DEF-PS-01 (refinement order) and INV-PS-02 (partial meet).
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Hyp(Vec<Atom>);
impl Hyp {
/// Creates a new hypothesis from a list of atoms.
///
/// Atoms are deduplicated and sorted by (system, code) to ensure consistency
/// between `PartialEq` (Vec comparison) and `PartialOrd` (HashSet comparison).
/// This enforces that duplicate atoms are collapsed to a single logical entry,
/// preventing INV-PS-02 violations where `meet()` would fail on semantically
/// identical hypotheses that differ only by atom duplication.
pub fn new(atoms: Vec<Atom>) -> Self {
let mut deduplicated: Vec<Atom> = atoms;
deduplicated.sort_by(|a, b| {
// Sort by (system, code) first, then by version for determinism
match a.system.cmp(&b.system) {
Ordering::Equal => match a.code.cmp(&b.code) {
Ordering::Equal => a.version.cmp(&b.version),
other => other,
},
other => other,
}
});
deduplicated.dedup();
Hyp(deduplicated)
}
/// Creates the top element (most general hypothesis, `Unknown`).
pub fn unknown() -> Self {
Hyp(vec![])
}
/// Checks if this hypothesis is compatible with another.
///
/// Two hypotheses are compatible if they satisfy INV-PS-01 (closed under refinement):
/// if h₁ ⊑ h₂ and compat(h₂, h₃), then compat(h₁, h₃).
///
/// This is achieved by checking: either (1) at least one is Unknown, or (2) the
/// atom sets have non-empty intersection (shared atoms). This ensures compatibility
/// is preserved under refinement: if h₁ ⊑ h₂ (h₁ has a superset of h₂'s atoms)
/// and h₂ and h₃ share atoms, then h₁ and h₃ must also share those atoms.
///
/// Per DEF-PS-01, this implements the compatibility predicate in terms of
/// ontological compatibility. In future versions, this may be determined by
/// ontological constraints (e.g., SNOMED CT's is-a hierarchy) rather than
/// simple atom-set intersection.
pub fn compat(&self, other: &Hyp) -> bool {
// Unknown is compatible with any hypothesis
if self.is_unknown() || other.is_unknown() {
return true;
}
// Check if atom sets have non-empty intersection
let self_atoms: std::collections::HashSet<_> = self.atoms().iter().collect();
let other_atoms: std::collections::HashSet<_> = other.atoms().iter().collect();
!self_atoms.is_disjoint(&other_atoms)
}
/// Computes the partial meet of two hypotheses.
///
/// The meet of h1 and h2 is the greatest lower bound (most general hypothesis that refines both).
/// Returns `None` if no meet exists (hypotheses are incompatible).
///
/// Implements INV-PS-02 (meet operation).
///
/// - If h1 and h2 are equal, their meet is h1.
/// - If one is `Unknown`, the meet is the other.
/// - Otherwise, no meet exists (returns `None`).
///
/// In future versions, meet will be determined by lattice structure over the ontology.
pub fn meet(&self, other: &Hyp) -> Option<Hyp> {
if self == other {
return Some(self.clone());
}
if self.is_unknown() {
return Some(other.clone());
}
if other.is_unknown() {
return Some(self.clone());
}
None
}
/// Checks if this is the `Unknown` hypothesis (top element).
fn is_unknown(&self) -> bool {
self.0.is_empty()
}
/// Returns the atoms in this hypothesis.
pub fn atoms(&self) -> &[Atom] {
&self.0
}
}
impl PartialOrd for Hyp {
/// Compares two hypotheses by refinement order via atom-set inclusion.
///
/// `self ⊑ other` (self refines other) iff `self.atoms() ⊇ other.atoms()` as sets:
/// more atoms = more specific (more refined). The empty atom set (`Unknown`) is
/// the top element; every hypothesis refines it.
///
/// - `Some(Less)`: `self` refines `other` (self has strictly more atoms).
/// - `Some(Greater)`: `other` refines `self` (self has strictly fewer atoms).
/// - `Some(Equal)`: atom sets coincide.
/// - `None`: atom sets are incomparable (neither is a subset of the other).
///
/// This implementation aligns `Hyp::PartialOrd` with the formal `⊑_PS` semantics
/// used in the OBL-PS-02 proof sketch and required for the Galois adjunction
/// `α_PS(e) ⊑ h ⟺ atoms(α_PS(e)) ⊇ atoms(h)` to hold beyond Unknown.
///
/// Implements DEF-PS-01 refinement order.
fn partial_cmp(&self, other: &Hyp) -> Option<Ordering> {
let self_atoms: HashSet<&Atom> = self.atoms().iter().collect();
let other_atoms: HashSet<&Atom> = other.atoms().iter().collect();
let self_subset_of_other = self_atoms.is_subset(&other_atoms);
let other_subset_of_self = other_atoms.is_subset(&self_atoms);
match (self_subset_of_other, other_subset_of_self) {
(true, true) => Some(Ordering::Equal),
(false, true) => Some(Ordering::Less), // self has strictly more atoms => more refined
(true, false) => Some(Ordering::Greater), // self has strictly fewer atoms => more general
(false, false) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ontology::OntologySystem;
fn atom_diagnosis_a() -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
}
}
fn atom_diagnosis_b() -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: "3723001".to_string(),
preferred_term: "Acute respiratory distress syndrome".to_string(),
version: "2026-01-31".to_string(),
}
}
fn atom_severity_high() -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: "24484000".to_string(),
preferred_term: "Severe".to_string(),
version: "2026-01-31".to_string(),
}
}
#[test]
fn test_unknown_top_element() {
let unknown = Hyp::unknown();
let hyp = Hyp::new(vec![atom_diagnosis_a()]);
// Any hypothesis refines (is more specific than) Unknown.
assert_eq!(hyp.partial_cmp(&unknown), Some(Ordering::Less));
// Unknown refines any hypothesis (is less specific).
assert_eq!(unknown.partial_cmp(&hyp), Some(Ordering::Greater));
}
#[test]
fn test_equal_hypotheses() {
let atom_a = atom_diagnosis_a();
let atom_s = atom_severity_high();
let h1 = Hyp::new(vec![atom_a.clone(), atom_s.clone()]);
let h2 = Hyp::new(vec![atom_a, atom_s]);
assert_eq!(h1.partial_cmp(&h2), Some(Ordering::Equal));
assert!(h1.compat(&h2));
}
#[test]
fn test_incomparable_hypotheses() {
let h1 = Hyp::new(vec![atom_diagnosis_a()]);
let h2 = Hyp::new(vec![atom_diagnosis_b()]);
// Incomparable hypotheses have no ordering relationship.
assert_eq!(h1.partial_cmp(&h2), None);
// Incomparable hypotheses are not compatible (unless one is Unknown).
assert!(!h1.compat(&h2));
}
#[test]
fn test_subset_refinement() {
// Atom-set inclusion: a superset hypothesis refines the subset one.
let h_general = Hyp::new(vec![atom_diagnosis_a()]);
let h_specific = Hyp::new(vec![atom_diagnosis_a(), atom_severity_high()]);
// h_specific refines h_general (more atoms => more specific).
assert_eq!(h_specific.partial_cmp(&h_general), Some(Ordering::Less));
assert_eq!(h_general.partial_cmp(&h_specific), Some(Ordering::Greater));
}
#[test]
fn test_compat_with_unknown() {
let unknown = Hyp::unknown();
let hyp = Hyp::new(vec![atom_diagnosis_a()]);
// Any hypothesis is compatible with Unknown.
assert!(hyp.compat(&unknown));
assert!(unknown.compat(&hyp));
}
#[test]
fn test_meet_equal_hypotheses() {
let atom = atom_diagnosis_a();
let h1 = Hyp::new(vec![atom.clone()]);
let h2 = Hyp::new(vec![atom]);
assert_eq!(h1.meet(&h2), Some(h1.clone()));
}
#[test]
fn test_meet_with_unknown() {
let unknown = Hyp::unknown();
let hyp = Hyp::new(vec![atom_diagnosis_a()]);
// Meet of any hypothesis with Unknown is the hypothesis itself.
assert_eq!(hyp.meet(&unknown), Some(hyp.clone()));
assert_eq!(unknown.meet(&hyp), Some(hyp.clone()));
}
#[test]
fn test_meet_incomparable_none() {
let h1 = Hyp::new(vec![atom_diagnosis_a()]);
let h2 = Hyp::new(vec![atom_diagnosis_b()]);
// Incomparable hypotheses have no meet.
assert_eq!(h1.meet(&h2), None);
}
#[test]
fn test_hyp_duplicate_atoms_deduplicated() {
let atom = atom_diagnosis_a();
// Create a Hyp with the same atom twice.
let h_with_dup = Hyp::new(vec![atom.clone(), atom.clone()]);
let h_deduped = Hyp::new(vec![atom]);
// After deduplication, they should be equal.
assert_eq!(h_with_dup, h_deduped);
// And they should have the same ordering comparison.
assert_eq!(h_with_dup.partial_cmp(&h_deduped), Some(Ordering::Equal));
// And meet should work (not return None).
assert_eq!(h_with_dup.meet(&h_deduped), Some(h_deduped));
}
#[test]
fn test_hyp_atoms_sorted_deterministically() {
let atom_a = atom_diagnosis_a();
let atom_b = atom_diagnosis_b();
let atom_s = atom_severity_high();
// Create hypotheses with atoms in different orders.
let h1 = Hyp::new(vec![atom_s.clone(), atom_a.clone(), atom_b.clone()]);
let h2 = Hyp::new(vec![atom_b.clone(), atom_s.clone(), atom_a.clone()]);
// After normalization, they should be equal (same sorted order).
assert_eq!(h1, h2);
// And atoms() should return them in sorted order.
let atoms = h1.atoms();
assert_eq!(atoms.len(), 3);
// Atoms should be sorted by (system, code).
for i in 1..atoms.len() {
assert!(
(atoms[i - 1].system, &atoms[i - 1].code) <= (atoms[i].system, &atoms[i].code),
"atoms not sorted: {:?} > {:?}",
atoms[i - 1],
atoms[i]
);
}
}
#[test]
fn test_compat_closed_under_refinement_inv_ps_01() {
// Property: if h₁ ⊑ h₂ and compat(h₂, h₃), then compat(h₁, h₃) (INV-PS-01).
// This test verifies compatibility is closed under refinement by checking
// atom-set intersection (shared atoms) => compatible.
let atom_a = atom_diagnosis_a();
let atom_b = atom_diagnosis_b();
let atom_s = atom_severity_high();
// Counterexample from task 3.7: h₁ = {a,b}, h₂ = Unknown, h₃ = {a,c}
// h₁ ⊑ h₂ (every hyp refines Unknown)
// compat(h₂, h₃) = true (Unknown compat with anything)
// compat(h₁, h₃) = true (both contain atom_a, so they share atoms)
let h1 = Hyp::new(vec![atom_a.clone(), atom_b.clone()]);
let h2_unknown = Hyp::unknown();
let h3 = Hyp::new(vec![atom_a.clone(), atom_s.clone()]);
assert!(h1 <= h2_unknown, "h₁ should refine h₂");
assert!(h2_unknown.compat(&h3), "h₂ should be compat with h₃");
assert!(
h1.compat(&h3),
"h₁ should be compat with h₃ (closure via shared atom_a)"
);
// Test 2: Incomparable hypotheses with no shared atoms are not compatible
let h_disjoint1 = Hyp::new(vec![atom_a.clone()]);
let h_disjoint2 = Hyp::new(vec![atom_b.clone()]);
assert!(
!h_disjoint1.compat(&h_disjoint2),
"disjoint atom sets should not be compatible"
);
// Test 3: Refined hypothesis preserves compatibility
// If h₂ = {a} and h₃ = {a,c}, they're compatible (shared atom_a).
// If h₁ = {a,b} ⊑ h₂ = {a}, then h₁ and h₃ should still be compatible.
let h2_concrete = Hyp::new(vec![atom_a.clone()]);
let h3_extended = Hyp::new(vec![atom_a.clone(), atom_s.clone()]);
let h1_refined = Hyp::new(vec![atom_a.clone(), atom_b.clone()]);
assert!(h1_refined <= h2_concrete, "h₁ should refine h₂");
assert!(
h2_concrete.compat(&h3_extended),
"h₂ should be compat with h₃"
);
assert!(
h1_refined.compat(&h3_extended),
"h₁ should be compat with h₃"
);
}
}