Skip to content

Commit 52edeb0

Browse files
authored
Merge pull request #582 from AdaWorldAPI/claude/medcare-bridge-lance-graph-wmx76z
MedcareBridge = UnifiedBridge<HealthcarePort> + FieldMask role projection + Fisher-z clamp fix
2 parents 96c1249 + 4417a6f commit 52edeb0

8 files changed

Lines changed: 281 additions & 52 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/lance-graph-contract/src/class_view.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,26 @@ impl FieldMask {
123123
self.0 == 0
124124
}
125125

126+
/// The full mask — every addressable field position present. The
127+
/// "no projection constraint" default for an RBAC role that has not
128+
/// narrowed its view (lance-graph-rbac `PermissionSpec::projection`).
129+
pub const FULL: Self = Self(u64::MAX);
130+
131+
/// Bitwise intersection — the field positions present in BOTH masks.
132+
#[inline]
133+
pub const fn intersect(self, other: Self) -> Self {
134+
Self(self.0 & other.0)
135+
}
136+
137+
/// Do the two masks share NO field position? RBAC uses this to assert
138+
/// two roles project **distinct** views of the same class — e.g. a
139+
/// research projection must be disjoint from the identifier fields
140+
/// (`classid :: role :: membership`, where the role is the projection).
141+
#[inline]
142+
pub const fn is_disjoint(self, other: Self) -> bool {
143+
self.0 & other.0 == 0
144+
}
145+
126146
/// Inherit a parent class's presence into this mask — the **mask-inherits-as-
127147
/// delta** of the HHTL `subClassOf` walk (`wikidata-hhtl-load.md`). A child
128148
/// IS-A its parent, so its mask carries every field the parent declares

crates/lance-graph-contract/src/distance.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ pub trait Distance: Sized {
4141
#[inline]
4242
fn similarity_z(&self, other: &Self) -> f32 {
4343
let s = self.similarity(other);
44-
let clamped = s.clamp(-0.999, 0.999);
44+
// Clamp away from ±1 so `atanh` (the `ln` below) stays finite.
45+
// The bound is ±0.9999, not ±0.999: a self-match (s = 1.0) must
46+
// round-trip back through `tanh(atanh(clamp)) = clamp` to a value
47+
// that reads as "essentially identical" (≈0.99986), not be capped
48+
// at 0.999 — otherwise `cohort_similarity_z(self) > 0.999` is
49+
// unreachable. atanh(0.9999) ≈ 4.95 is comfortably finite.
50+
let clamped = s.clamp(-0.9999, 0.9999);
4551
((1.0 + clamped) / (1.0 - clamped)).ln() * 0.5
4652
}
4753
}

crates/lance-graph-ontology/Cargo.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ lance-graph-contract = { path = "../lance-graph-contract" }
1717
# (OpenProjectBridge, RedmineBridge, …) are type aliases over the
1818
# harness — the namespace / bridge_id / public-name-alias data all
1919
# come from OGAR class schema, not from this crate.
20-
# Pinned to the OGAR `claude/port-spec-trait` branch until the OGAR PR
21-
# adding the `ports` module merges to main; will move to `branch =
22-
# "main"` in a follow-up.
23-
ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "claude/port-spec-trait" }
20+
# Pinned to the OGAR `claude/medcare-bridge-lance-graph-wmx76z` branch:
21+
# it carries `ports::PortSpec` + the project-mgmt ports (from
22+
# port-spec-trait) AND the new `HealthcarePort` + 0x09XX Health codebook
23+
# that `MedcareBridge = UnifiedBridge<HealthcarePort>` needs. Will move
24+
# to `branch = "main"` once both OGAR PRs merge.
25+
ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "claude/medcare-bridge-lance-graph-wmx76z" }
2426

2527
# TTL parser. oxttl is the smallest streaming Turtle parser in the workspace's
2628
# dependency graph and matches the shape of OGIT's per-entity .ttl files.
Lines changed: 157 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,166 @@
1-
//! MedCare (healthcare) tenant bridge — locks to the `Healthcare`
2-
//! namespace. The Healthcare namespace itself is reserved and will be
3-
//! populated by a future session (the FMA / SNOMED / RadLex import is the
4-
//! remit of `lance-graph-rdf` in `lance-graph-rdf-fma-snomed-v1`).
5-
6-
use crate::bridge::{BridgeFromRegistry, NamespaceBridge};
7-
use crate::error::{Error, Result};
8-
use crate::namespace::NamespaceId;
9-
use crate::registry::OntologyRegistry;
10-
use std::sync::Arc;
11-
12-
pub const NAMESPACE: &str = "Healthcare";
13-
14-
pub struct MedcareBridge {
15-
registry: Arc<OntologyRegistry>,
16-
g_lock: NamespaceId,
17-
}
1+
//! MedCare (healthcare) tenant bridge — now a thin type alias over
2+
//! [`crate::bridges::unified::UnifiedBridge`] parameterised by
3+
//! [`ogar_vocab::ports::HealthcarePort`].
4+
//!
5+
//! Before the Healthcare codebook promotion (Northstar T9) this file
6+
//! carried a bespoke `MedcareBridge` struct + hand-written
7+
//! `NamespaceBridge` impl — the same boilerplate every per-tenant bridge
8+
//! cloned. lance-graph#570 collapsed `OpenProjectBridge` / `RedmineBridge`
9+
//! onto the generic [`UnifiedBridge<P>`] harness but explicitly deferred
10+
//! `MedcareBridge` "until Healthcare gets promoted into the codebook".
11+
//! That promotion has now landed: OGAR mints the `0x09XX` Health concepts
12+
//! and `ports::HealthcarePort` carries the namespace / bridge_id / public-
13+
//! name → class_id alias table, so this bridge becomes one line.
14+
//!
15+
//! The differences between bridges (namespace, bridge_id, alias table)
16+
//! all come from the OGAR class schema. Codebook public names (`Patient`,
17+
//! `Diagnosis`, …) synthesize an `EntityRef` whose `entity_type_id()` is
18+
//! the canonical Health class_id; names outside the alias table fall
19+
//! through to the registry-resolution path (so a hydrated TTL entity that
20+
//! is not yet a codebook concept still resolves). The audit / authorization
21+
//! path uses `row()` (registry-backed, not overridden here), so it is
22+
//! unaffected by codebook synthesis on `entity()`.
23+
//!
24+
//! See `crate::bridges::unified::tests` for the generic-level coverage and
25+
//! `tests/bridge_scope_lock.rs` for the Healthcare scope-lock pins.
26+
27+
use crate::bridges::unified::UnifiedBridge;
28+
// `HealthcarePort::NAMESPACE` / `::aliases()` are `PortSpec` associated
29+
// items — the trait must be in scope for the resolution to work (codex
30+
// P1 on PR #570). Same import in the test module below.
31+
use ogar_vocab::ports::PortSpec;
32+
pub use ogar_vocab::ports::HealthcarePort;
33+
34+
/// MedCare `NamespaceBridge` — alias over the generic harness, locked to
35+
/// the `Healthcare` namespace via [`HealthcarePort`].
36+
pub type MedcareBridge = UnifiedBridge<HealthcarePort>;
37+
38+
/// Canonical namespace name for MedCare / Healthcare. Mirrors
39+
/// `HealthcarePort::NAMESPACE` so existing consumers that imported the
40+
/// constant from this module keep building.
41+
pub const NAMESPACE: &str = HealthcarePort::NAMESPACE;
42+
43+
#[cfg(test)]
44+
mod tests {
45+
//! Co-located unit tests for the migrated alias — constructor
46+
//! success/failure, contract methods, codebook resolution, fallback
47+
//! to registry. Mirrors `openproject_bridge::tests`; only the
48+
//! `MedcareBridge` ident (now `UnifiedBridge<HealthcarePort>`) and the
49+
//! Healthcare fixtures differ.
1850
19-
impl MedcareBridge {
20-
pub fn new(registry: Arc<OntologyRegistry>) -> Result<Self> {
21-
let g_lock = registry
22-
.namespace_id(NAMESPACE)
23-
.ok_or_else(|| Error::UnknownNamespace(NAMESPACE.to_string()))?;
24-
Ok(Self { registry, g_lock })
51+
use super::*;
52+
use crate::bridge::{BridgeError, NamespaceBridge};
53+
use crate::error::Error;
54+
use crate::namespace::NamespaceId;
55+
use crate::namespace_registry::NamespaceRegistry;
56+
use crate::registry::OntologyRegistry;
57+
use ogar_vocab::class_ids;
58+
// PortSpec needed in scope for `HealthcarePort::aliases()`.
59+
use ogar_vocab::ports::PortSpec;
60+
use std::fs;
61+
use std::sync::Arc;
62+
63+
fn registry_with_healthcare() -> Arc<OntologyRegistry> {
64+
let ttl = r#"
65+
@prefix ogit: <http://www.purl.org/ogit/> .
66+
@prefix ogit.Healthcare: <http://www.purl.org/ogit/Healthcare/> .
67+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
68+
69+
ogit.Healthcare:Patient
70+
a rdfs:Class;
71+
rdfs:subClassOf ogit:Entity;
72+
rdfs:label "Patient";
73+
ogit:scope "NTO";
74+
ogit:parent ogit:Node;
75+
ogit:mandatory-attributes ( ogit:id );
76+
ogit:optional-attributes ( ) ;
77+
.
78+
"#;
79+
let tmp = tempfile::tempdir().unwrap();
80+
fs::create_dir_all(tmp.path().join("Healthcare")).unwrap();
81+
fs::write(tmp.path().join("Healthcare").join("ents.ttl"), ttl).unwrap();
82+
let registry = Arc::new(OntologyRegistry::new_in_memory());
83+
registry
84+
.hydrate_once_sync(tmp.path(), &["Healthcare"])
85+
.unwrap();
86+
std::mem::forget(tmp);
87+
registry
2588
}
26-
}
2789

28-
impl NamespaceBridge for MedcareBridge {
29-
fn bridge_id(&self) -> &'static str {
30-
"medcare"
90+
#[test]
91+
fn new_succeeds_when_namespace_registered() {
92+
let registry = registry_with_healthcare();
93+
let bridge = MedcareBridge::new(registry).unwrap();
94+
assert_ne!(bridge.g_lock(), NamespaceId::UNKNOWN);
3195
}
32-
fn registry(&self) -> &OntologyRegistry {
33-
&self.registry
96+
97+
#[test]
98+
fn new_returns_unknown_namespace_when_not_registered() {
99+
let registry = Arc::new(OntologyRegistry::new_in_memory());
100+
// `unwrap_err()` would require `MedcareBridge: Debug`, which the
101+
// underlying `UnifiedBridge<P>` intentionally doesn't impl.
102+
match MedcareBridge::new(registry) {
103+
Ok(_) => panic!("expected UnknownNamespace, got Ok(_)"),
104+
Err(Error::UnknownNamespace(name)) => assert_eq!(name, "Healthcare"),
105+
Err(other) => panic!("expected UnknownNamespace, got {other:?}"),
106+
}
34107
}
35-
fn g_lock(&self) -> NamespaceId {
36-
self.g_lock
108+
109+
#[test]
110+
fn bridge_id_is_lowercase_medcare() {
111+
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
112+
assert_eq!(bridge.bridge_id(), "medcare");
113+
}
114+
115+
#[test]
116+
fn g_lock_matches_registry_namespace_id() {
117+
let registry = registry_with_healthcare();
118+
let expected = registry.namespace_id(NAMESPACE).unwrap();
119+
let bridge = MedcareBridge::new(registry).unwrap();
120+
assert_eq!(bridge.g_lock(), expected);
121+
}
122+
123+
#[test]
124+
fn entity_resolves_patient_to_canonical_class_id() {
125+
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
126+
let entity = bridge.entity("Patient").unwrap();
127+
assert_eq!(entity.schema_ptr.entity_type_id(), class_ids::PATIENT);
128+
assert_eq!(entity.schema_ptr.entity_type_id(), 0x0901);
129+
}
130+
131+
#[test]
132+
fn entity_synthesised_schema_ptr_stamps_seeded_context_id() {
133+
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
134+
let entity = bridge.entity("Patient").unwrap();
135+
let expected = NamespaceRegistry::seed_context_id("Healthcare").unwrap();
136+
assert_eq!(entity.schema_ptr.ontology_context_id(), expected);
137+
assert_eq!(entity.schema_ptr.ontology_context_id(), 2);
138+
}
139+
140+
#[test]
141+
fn entity_for_each_codebook_entry_returns_its_canonical_class_id() {
142+
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
143+
for &(public_name, expected_id) in HealthcarePort::aliases() {
144+
let entity = bridge.entity(public_name).unwrap_or_else(|e| {
145+
panic!("codebook entry `{public_name}` failed to resolve: {e:?}")
146+
});
147+
assert_eq!(
148+
entity.schema_ptr.entity_type_id(),
149+
expected_id,
150+
"codebook entry `{public_name}` should resolve to 0x{expected_id:04X}",
151+
);
152+
}
37153
}
38-
}
39154

40-
impl BridgeFromRegistry for MedcareBridge {
41-
fn from_registry(registry: Arc<OntologyRegistry>) -> Result<Self> {
42-
Self::new(registry)
155+
#[test]
156+
fn entity_for_non_codebook_name_falls_back_to_registry_lookup() {
157+
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
158+
let err = bridge.entity("NotAConcept").unwrap_err();
159+
match err {
160+
BridgeError::NotInScope { public_name, .. } => {
161+
assert_eq!(public_name, "NotAConcept")
162+
}
163+
other => panic!("expected NotInScope, got {other:?}"),
164+
}
43165
}
44166
}

crates/lance-graph-ontology/src/bridges/mod.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
//! (`NAMESPACE` / `BRIDGE_ID` / public-name → class_id aliases) from
99
//! [`ogar_vocab::ports::PortSpec`]. Adding a port is `impl PortSpec
1010
//! for FooPort {…}` in OGAR — no bridge boilerplate here.
11-
//! - The **legacy per-tenant bridges** ([`WoaBridge`], [`MedcareBridge`],
12-
//! [`SpearBridge`], [`SharePointBridge`], [`OgitBridge`]) keep their
13-
//! bespoke struct shape for now. They predate OGAR's codebook and
14-
//! don't yet have a `PortSpec` impl in `ogar-vocab::ports`. When the
15-
//! WorkOrder / Healthcare / EmailCorrespondance / SharePoint
16-
//! namespaces get promoted into the codebook, these collapse the
17-
//! same way OpenProject and Redmine just did.
11+
//! - The **legacy per-tenant bridges** ([`WoaBridge`], [`SpearBridge`],
12+
//! [`SharePointBridge`], [`OgitBridge`]) keep their bespoke struct
13+
//! shape for now. They predate OGAR's codebook and don't yet have a
14+
//! `PortSpec` impl in `ogar-vocab::ports`. When the WorkOrder /
15+
//! EmailCorrespondance / SharePoint namespaces get promoted into the
16+
//! codebook, these collapse the same way OpenProject, Redmine, and
17+
//! MedCare already did.
1818
//!
19-
//! # Project-management ports (`UnifiedBridge<P>` aliases)
19+
//! # OGAR-driven ports (`UnifiedBridge<P>` aliases)
2020
//!
2121
//! - [`OpenProjectBridge`]: `UnifiedBridge<ogar_vocab::ports::OpenProjectPort>`
2222
//! — locks to the `OpenProject` namespace. `WorkPackage` / `TimeEntry`
@@ -27,14 +27,19 @@
2727
//! etc. resolve to the SAME OGAR canonical class_ids as the
2828
//! OpenProject equivalents, so cross-fork convergence is the default
2929
//! not the exception.
30+
//! - [`MedcareBridge`]: `UnifiedBridge<ogar_vocab::ports::HealthcarePort>`
31+
//! — locks to the `Healthcare` namespace. `Patient` / `Diagnosis` /
32+
//! `LabValue` / `Medication` / `Treatment` / `Visit` / `VitalSign`
33+
//! resolve to the `0x09XX` Health codebook (Northstar T9). Single-
34+
//! tenant today; a future FMA / SNOMED curator converges on the same
35+
//! ids.
3036
//!
3137
//! # Per-tenant bridges (legacy struct shape)
3238
//!
3339
//! - [`OgitBridge`]: pass-through bridge for tools that already speak raw
3440
//! OGIT URIs. `bridge_id = "ogit"`. Locks to whatever namespace its
3541
//! constructor is called with.
3642
//! - [`WoaBridge`]: locks to the `WorkOrder` namespace.
37-
//! - [`MedcareBridge`]: locks to the `Healthcare` namespace.
3843
//! - [`SpearBridge`]: locks to the `EmailCorrespondance` namespace.
3944
//! - [`SharePointBridge`]: locks to the `SharePoint` namespace.
4045
//!
@@ -53,7 +58,7 @@ mod sharepoint_bridge;
5358
mod spear_bridge;
5459
mod woa_bridge;
5560

56-
pub use medcare_bridge::MedcareBridge;
61+
pub use medcare_bridge::{HealthcarePort, MedcareBridge};
5762
pub use ogit_bridge::OgitBridge;
5863
pub use openproject_bridge::{OpenProjectBridge, OpenProjectPort};
5964
pub use redmine_bridge::{RedmineBridge, RedminePort};

crates/lance-graph-ontology/tests/openproject_bridge_scope_lock.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,12 @@ fn openproject_bridge_construction_fails_when_namespace_missing() {
132132
// Error::UnknownNamespace.
133133
let registry = Arc::new(OntologyRegistry::new_in_memory());
134134
let result = OpenProjectBridge::new(registry);
135+
// NB: `OpenProjectBridge` is `UnifiedBridge<OpenProjectPort>`, which
136+
// intentionally does not implement `Debug`, so we assert on `is_err()`
137+
// without formatting the `Ok` value (pre-#570 the bridge was a Debug
138+
// struct and this message interpolated `{result:?}`).
135139
assert!(
136140
result.is_err(),
137-
"expected UnknownNamespace error, got {result:?}",
141+
"expected UnknownNamespace error from constructing over an empty registry",
138142
);
139143
}

0 commit comments

Comments
 (0)