Skip to content

Commit 5c31a7b

Browse files
author
Frank Dekervel
committed
runtime: add LinkMLValue::equals per Instances spec; tests: add equality coverage; revert CLI offline toggle and run tests with network
1 parent e078ce9 commit 5c31a7b

File tree

3 files changed

+321
-0
lines changed

3 files changed

+321
-0
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
- Python (bindings helpers): follow PEP 8; prefer type hints where feasible.
2626

2727
## Testing Guidelines
28+
- When testing locally, always provide network access. never try to run the tests offline
2829
- Add integration tests under `src/runtime/tests/` when changing CLI/runtime behavior.
2930
- Prefer `assert_cmd` for CLI and `predicates` for output checks. Keep fixtures in `src/runtime/tests/data/`.
3031
- Run `cargo test --workspace` locally; ensure tests don’t rely on network input.

src/runtime/src/lib.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,149 @@ impl LinkMLValue {
153153
}
154154
Some(current)
155155
}
156+
157+
/// Compare two LinkMLValue instances for semantic equality per the
158+
/// LinkML Instances specification (Identity conditions).
159+
///
160+
/// Key points implemented:
161+
/// - Null equals Null.
162+
/// - Scalars: equal iff same underlying atomic value and compatible typed context
163+
/// (same Enum range when present; otherwise same TypeDefinition range name when present).
164+
/// - Lists: equal iff same length and pairwise equal in order.
165+
/// - Mappings: equal iff same keys and values equal for each key (order-insensitive).
166+
/// - Objects: equal iff same instantiated class (by identity) and slot assignments match
167+
/// after normalization (i.e., treating assignments with value Null as omitted), ignoring order.
168+
pub fn equals(&self, other: &LinkMLValue) -> bool {
169+
use LinkMLValue::*;
170+
match (self, other) {
171+
(Null { .. }, Null { .. }) => true,
172+
(
173+
Scalar {
174+
value: v1,
175+
slot: s1,
176+
..
177+
},
178+
Scalar {
179+
value: v2,
180+
slot: s2,
181+
..
182+
},
183+
) => {
184+
// If either slot has an enum range, both must and enum names must match
185+
let e1 = s1.get_range_enum();
186+
let e2 = s2.get_range_enum();
187+
if e1.is_some() || e2.is_some() {
188+
match (e1, e2) {
189+
(Some(ev1), Some(ev2)) => {
190+
if ev1.schema_id() != ev2.schema_id() || ev1.name() != ev2.name() {
191+
return false;
192+
}
193+
}
194+
_ => return false,
195+
}
196+
} else {
197+
// Compare type ranges if explicitly set on both
198+
let t1 = s1.definition().range.as_ref();
199+
let t2 = s2.definition().range.as_ref();
200+
if let (Some(r1), Some(r2)) = (t1, t2) {
201+
if r1 != r2 {
202+
return false;
203+
}
204+
}
205+
}
206+
v1 == v2
207+
}
208+
(List { values: a, .. }, List { values: b, .. }) => {
209+
if a.len() != b.len() {
210+
return false;
211+
}
212+
for (x, y) in a.iter().zip(b.iter()) {
213+
if !x.equals(y) {
214+
return false;
215+
}
216+
}
217+
true
218+
}
219+
(Mapping { values: a, .. }, Mapping { values: b, .. }) => {
220+
if a.len() != b.len() {
221+
return false;
222+
}
223+
for (k, va) in a.iter() {
224+
match b.get(k) {
225+
Some(vb) => {
226+
if !va.equals(vb) {
227+
return false;
228+
}
229+
}
230+
None => return false,
231+
}
232+
}
233+
true
234+
}
235+
(
236+
Object {
237+
values: a,
238+
class: ca,
239+
sv: sva,
240+
..
241+
},
242+
Object {
243+
values: b,
244+
class: cb,
245+
sv: svb,
246+
..
247+
},
248+
) => {
249+
// Compare class identity via canonical URIs if possible
250+
let ida = ca.canonical_uri();
251+
let idb = cb.canonical_uri();
252+
let class_equal = if let Some(conv) = sva.converter_for_schema(ca.schema_id()) {
253+
// Use 'sva' for comparison; identifiers are global across schemas
254+
sva.identifier_equals(&ida, &idb, conv).unwrap_or(false)
255+
} else if let Some(conv) = svb.converter_for_schema(cb.schema_id()) {
256+
svb.identifier_equals(&ida, &idb, conv).unwrap_or(false)
257+
} else {
258+
ca.name() == cb.name()
259+
};
260+
if !class_equal {
261+
return false;
262+
}
263+
264+
// Normalize conceptually by ignoring entries whose value is Null
265+
let count_a = a.iter().filter(|(_, v)| !matches!(v, Null { .. })).count();
266+
let count_b = b.iter().filter(|(_, v)| !matches!(v, Null { .. })).count();
267+
if count_a != count_b {
268+
return false;
269+
}
270+
for (k, va) in a.iter().filter(|(_, v)| !matches!(v, Null { .. })) {
271+
match b.get(k) {
272+
Some(vb) => {
273+
if matches!(vb, Null { .. }) {
274+
return false;
275+
}
276+
if !va.equals(vb) {
277+
return false;
278+
}
279+
}
280+
None => return false,
281+
}
282+
}
283+
// Ensure b has no extra non-null keys not in a (counts already equal, so this is defensive)
284+
for (k, _vb) in b.iter().filter(|(_, v)| !matches!(v, Null { .. })) {
285+
match a.get(k) {
286+
Some(va) => {
287+
if matches!(va, Null { .. }) {
288+
return false;
289+
}
290+
}
291+
None => return false,
292+
}
293+
}
294+
true
295+
}
296+
_ => false,
297+
}
298+
}
156299
fn find_scalar_slot_for_inlined_map(
157300
class: &ClassView,
158301
key_slot_name: &str,

src/runtime/tests/equality.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
use linkml_runtime::{load_json_str, load_yaml_str, LinkMLValue};
2+
use linkml_schemaview::identifier::converter_from_schema;
3+
use linkml_schemaview::io::from_yaml;
4+
use linkml_schemaview::schemaview::SchemaView;
5+
use std::path::Path;
6+
7+
fn data_path(name: &str) -> std::path::PathBuf {
8+
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
9+
p.push("tests");
10+
p.push("data");
11+
p.push(name);
12+
p
13+
}
14+
15+
#[test]
16+
fn object_equality_ignores_null_assignments() {
17+
// Load personinfo schema and Container class
18+
let schema = from_yaml(Path::new(&data_path("personinfo.yaml"))).unwrap();
19+
let mut sv = SchemaView::new();
20+
sv.add_schema(schema.clone()).unwrap();
21+
let conv = converter_from_schema(&schema);
22+
let container = sv
23+
.get_class(
24+
&linkml_schemaview::identifier::Identifier::new("Container"),
25+
&conv,
26+
)
27+
.unwrap()
28+
.expect("class not found");
29+
30+
let doc_with_null = r#"
31+
objects:
32+
- objecttype: personinfo:Person
33+
id: "P:1"
34+
name: "Alice"
35+
current_address: null
36+
"#;
37+
let doc_without_slot = r#"
38+
objects:
39+
- objecttype: personinfo:Person
40+
id: "P:1"
41+
name: "Alice"
42+
"#;
43+
let v1 = load_yaml_str(doc_with_null, &sv, &container, &conv).unwrap();
44+
let v2 = load_yaml_str(doc_without_slot, &sv, &container, &conv).unwrap();
45+
let p1 = v1.navigate_path(["objects", "0"]).unwrap();
46+
let p2 = v2.navigate_path(["objects", "0"]).unwrap();
47+
assert!(
48+
p1.equals(p2),
49+
"Person with null assignment should equal omission"
50+
);
51+
}
52+
53+
#[test]
54+
fn list_identity_is_order_sensitive() {
55+
let schema = from_yaml(Path::new(&data_path("personinfo.yaml"))).unwrap();
56+
let mut sv = SchemaView::new();
57+
sv.add_schema(schema.clone()).unwrap();
58+
let conv = converter_from_schema(&schema);
59+
let container = sv
60+
.get_class(
61+
&linkml_schemaview::identifier::Identifier::new("Container"),
62+
&conv,
63+
)
64+
.unwrap()
65+
.expect("class not found");
66+
67+
let doc_a = r#"
68+
objects:
69+
- objecttype: personinfo:Person
70+
id: "P:1"
71+
name: "Alice"
72+
has_employment_history:
73+
- started_at_time: 2019-01-01
74+
is_current: true
75+
- started_at_time: 2020-01-01
76+
is_current: false
77+
"#;
78+
let doc_b = r#"
79+
objects:
80+
- objecttype: personinfo:Person
81+
id: "P:1"
82+
name: "Alice"
83+
has_employment_history:
84+
- started_at_time: 2020-01-01
85+
is_current: false
86+
- started_at_time: 2019-01-01
87+
is_current: true
88+
"#;
89+
let v1 = load_yaml_str(doc_a, &sv, &container, &conv).unwrap();
90+
let v2 = load_yaml_str(doc_b, &sv, &container, &conv).unwrap();
91+
let p1 = v1.navigate_path(["objects", "0"]).unwrap();
92+
let p2 = v2.navigate_path(["objects", "0"]).unwrap();
93+
assert!(matches!(p1, LinkMLValue::Object { .. }));
94+
assert!(matches!(p2, LinkMLValue::Object { .. }));
95+
assert!(!p1.equals(p2), "List order must affect equality");
96+
}
97+
98+
#[test]
99+
fn mapping_equality_is_key_based_not_ordered() {
100+
// Load mapping schema and Bag class
101+
let schema = from_yaml(Path::new(&data_path("mapping_schema.yaml"))).unwrap();
102+
let mut sv = SchemaView::new();
103+
sv.add_schema(schema.clone()).unwrap();
104+
let conv = converter_from_schema(&schema);
105+
let bag = sv
106+
.get_class(
107+
&linkml_schemaview::identifier::Identifier::new("Bag"),
108+
&conv,
109+
)
110+
.unwrap()
111+
.expect("class not found");
112+
113+
let doc1 = r#"{
114+
"things": {
115+
"alpha": {"typeURI": "ThingA", "a_only": "foo", "common": "shared"},
116+
"beta": {"typeURI": "ThingB", "b_only": true, "common": "shared"}
117+
}
118+
}"#;
119+
let doc2 = r#"{
120+
"things": {
121+
"beta": {"typeURI": "ThingB", "b_only": true, "common": "shared"},
122+
"alpha": {"typeURI": "ThingA", "a_only": "foo", "common": "shared"}
123+
}
124+
}"#;
125+
let v1 = load_json_str(doc1, &sv, &bag, &conv).unwrap();
126+
let v2 = load_json_str(doc2, &sv, &bag, &conv).unwrap();
127+
let m1 = v1.navigate_path(["things"]).unwrap();
128+
let m2 = v2.navigate_path(["things"]).unwrap();
129+
assert!(matches!(m1, LinkMLValue::Mapping { .. }));
130+
assert!(matches!(m2, LinkMLValue::Mapping { .. }));
131+
assert!(m1.equals(m2), "Mapping equality should ignore key order");
132+
}
133+
134+
#[test]
135+
fn enum_scalar_equality_respects_value_and_range() {
136+
let schema = from_yaml(Path::new(&data_path("personinfo.yaml"))).unwrap();
137+
let mut sv = SchemaView::new();
138+
sv.add_schema(schema.clone()).unwrap();
139+
let conv = converter_from_schema(&schema);
140+
let container = sv
141+
.get_class(
142+
&linkml_schemaview::identifier::Identifier::new("Container"),
143+
&conv,
144+
)
145+
.unwrap()
146+
.expect("class not found");
147+
148+
let doc1 = r#"
149+
objects:
150+
- objecttype: personinfo:Person
151+
id: "P:1"
152+
name: "Alice"
153+
gender: "cisgender man"
154+
"#;
155+
let doc2 = r#"
156+
objects:
157+
- objecttype: personinfo:Person
158+
id: "P:2"
159+
name: "Bob"
160+
gender: "cisgender man"
161+
"#;
162+
let doc3 = r#"
163+
objects:
164+
- objecttype: personinfo:Person
165+
id: "P:3"
166+
name: "Carol"
167+
gender: "cisgender woman"
168+
"#;
169+
let v1 = load_yaml_str(doc1, &sv, &container, &conv).unwrap();
170+
let v2 = load_yaml_str(doc2, &sv, &container, &conv).unwrap();
171+
let v3 = load_yaml_str(doc3, &sv, &container, &conv).unwrap();
172+
let g1 = v1.navigate_path(["objects", "0", "gender"]).unwrap();
173+
let g2 = v2.navigate_path(["objects", "0", "gender"]).unwrap();
174+
let g3 = v3.navigate_path(["objects", "0", "gender"]).unwrap();
175+
assert!(g1.equals(g2));
176+
assert!(!g1.equals(g3));
177+
}

0 commit comments

Comments
 (0)