Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions crates/lance-graph-contract/src/class_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ impl FieldMask {
self.0 == 0
}

/// The full mask — every addressable field position present. The
/// "no projection constraint" default for an RBAC role that has not
/// narrowed its view (lance-graph-rbac `PermissionSpec::projection`).
pub const FULL: Self = Self(u64::MAX);

/// Bitwise intersection — the field positions present in BOTH masks.
#[inline]
pub const fn intersect(self, other: Self) -> Self {
Self(self.0 & other.0)
}

/// Do the two masks share NO field position? RBAC uses this to assert
/// two roles project **distinct** views of the same class — e.g. a
/// research projection must be disjoint from the identifier fields
/// (`classid :: role :: membership`, where the role is the projection).
#[inline]
pub const fn is_disjoint(self, other: Self) -> bool {
self.0 & other.0 == 0
}

/// Inherit a parent class's presence into this mask — the **mask-inherits-as-
/// delta** of the HHTL `subClassOf` walk (`wikidata-hhtl-load.md`). A child
/// IS-A its parent, so its mask carries every field the parent declares
Expand Down
8 changes: 7 additions & 1 deletion crates/lance-graph-contract/src/distance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ pub trait Distance: Sized {
#[inline]
fn similarity_z(&self, other: &Self) -> f32 {
let s = self.similarity(other);
let clamped = s.clamp(-0.999, 0.999);
// Clamp away from ±1 so `atanh` (the `ln` below) stays finite.
// The bound is ±0.9999, not ±0.999: a self-match (s = 1.0) must
// round-trip back through `tanh(atanh(clamp)) = clamp` to a value
// that reads as "essentially identical" (≈0.99986), not be capped
// at 0.999 — otherwise `cohort_similarity_z(self) > 0.999` is
// unreachable. atanh(0.9999) ≈ 4.95 is comfortably finite.
let clamped = s.clamp(-0.9999, 0.9999);
((1.0 + clamped) / (1.0 - clamped)).ln() * 0.5
}
}
Expand Down
10 changes: 6 additions & 4 deletions crates/lance-graph-ontology/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ lance-graph-contract = { path = "../lance-graph-contract" }
# (OpenProjectBridge, RedmineBridge, …) are type aliases over the
# harness — the namespace / bridge_id / public-name-alias data all
# come from OGAR class schema, not from this crate.
# Pinned to the OGAR `claude/port-spec-trait` branch until the OGAR PR
# adding the `ports` module merges to main; will move to `branch =
# "main"` in a follow-up.
ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "claude/port-spec-trait" }
# Pinned to the OGAR `claude/medcare-bridge-lance-graph-wmx76z` branch:
# it carries `ports::PortSpec` + the project-mgmt ports (from
# port-spec-trait) AND the new `HealthcarePort` + 0x09XX Health codebook
# that `MedcareBridge = UnifiedBridge<HealthcarePort>` needs. Will move
# to `branch = "main"` once both OGAR PRs merge.
ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "claude/medcare-bridge-lance-graph-wmx76z" }

# TTL parser. oxttl is the smallest streaming Turtle parser in the workspace's
# dependency graph and matches the shape of OGIT's per-entity .ttl files.
Expand Down
192 changes: 157 additions & 35 deletions crates/lance-graph-ontology/src/bridges/medcare_bridge.rs
Original file line number Diff line number Diff line change
@@ -1,44 +1,166 @@
//! MedCare (healthcare) tenant bridge — locks to the `Healthcare`
//! namespace. The Healthcare namespace itself is reserved and will be
//! populated by a future session (the FMA / SNOMED / RadLex import is the
//! remit of `lance-graph-rdf` in `lance-graph-rdf-fma-snomed-v1`).

use crate::bridge::{BridgeFromRegistry, NamespaceBridge};
use crate::error::{Error, Result};
use crate::namespace::NamespaceId;
use crate::registry::OntologyRegistry;
use std::sync::Arc;

pub const NAMESPACE: &str = "Healthcare";

pub struct MedcareBridge {
registry: Arc<OntologyRegistry>,
g_lock: NamespaceId,
}
//! MedCare (healthcare) tenant bridge — now a thin type alias over
//! [`crate::bridges::unified::UnifiedBridge`] parameterised by
//! [`ogar_vocab::ports::HealthcarePort`].
//!
//! Before the Healthcare codebook promotion (Northstar T9) this file
//! carried a bespoke `MedcareBridge` struct + hand-written
//! `NamespaceBridge` impl — the same boilerplate every per-tenant bridge
//! cloned. lance-graph#570 collapsed `OpenProjectBridge` / `RedmineBridge`
//! onto the generic [`UnifiedBridge<P>`] harness but explicitly deferred
//! `MedcareBridge` "until Healthcare gets promoted into the codebook".
//! That promotion has now landed: OGAR mints the `0x09XX` Health concepts
//! and `ports::HealthcarePort` carries the namespace / bridge_id / public-
//! name → class_id alias table, so this bridge becomes one line.
//!
//! The differences between bridges (namespace, bridge_id, alias table)
//! all come from the OGAR class schema. Codebook public names (`Patient`,
//! `Diagnosis`, …) synthesize an `EntityRef` whose `entity_type_id()` is
//! the canonical Health class_id; names outside the alias table fall
//! through to the registry-resolution path (so a hydrated TTL entity that
//! is not yet a codebook concept still resolves). The audit / authorization
//! path uses `row()` (registry-backed, not overridden here), so it is
//! unaffected by codebook synthesis on `entity()`.
//!
//! See `crate::bridges::unified::tests` for the generic-level coverage and
//! `tests/bridge_scope_lock.rs` for the Healthcare scope-lock pins.

use crate::bridges::unified::UnifiedBridge;
// `HealthcarePort::NAMESPACE` / `::aliases()` are `PortSpec` associated
// items — the trait must be in scope for the resolution to work (codex
// P1 on PR #570). Same import in the test module below.
use ogar_vocab::ports::PortSpec;
pub use ogar_vocab::ports::HealthcarePort;

/// MedCare `NamespaceBridge` — alias over the generic harness, locked to
/// the `Healthcare` namespace via [`HealthcarePort`].
pub type MedcareBridge = UnifiedBridge<HealthcarePort>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep MedCare codebook aliases row-backed

When this alias opts MedCare into UnifiedBridge's codebook entity() path, Healthcare aliases such as Diagnosis can resolve from HealthcarePort even when the registry only has the namespace. The existing authorization/audit path in lance-graph-callcenter calls NamespaceBridge::row(public_name), and the default row() still requires an actual registry row with bridge_id == "medcare"; normal TTL hydration files rows under "ogit", and the new fixture only seeds Patient. In that setup the advertised codebook aliases resolve via entity() but fail before policy evaluation or audit, so either synthesize/override row() for these aliases or seed medcare rows for every alias.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and confirmed concretely: TTL hydration files mapping rows under bridge_id = "ogit" (registry.rs:139), while the default NamespaceBridge::row() filters by the bridge's own id ("medcare") — per-bridge rows come from a separate append_mapping step, not hydration. So entity() (codebook synthesis) and row() (registry-backed, the authz/audit path) can diverge for a codebook alias.

But this is a property of the shared UnifiedBridge<P> harness (lance-graph#570), not introduced by this PR: MedcareBridge is literally type MedcareBridge = UnifiedBridge<HealthcarePort>, identical to the already-merged OpenProjectBridge/RedmineBridge — none of the three override row(). The minimal fixture seeds only Patient via TTL to exercise namespace registration + entity() resolution + the not-in-scope fallback, not the full authz path.

The real fix — row() synthesizing from the codebook when no per-bridge mapping row exists — belongs in UnifiedBridge<P> (it would cover all three bridges) and touches the authz/audit path, so it's a deliberate harness change rather than something to slip into this alias PR (a type alias can't override row() anyway). Leaving this thread open as the tracking item — happy to do the harness change as a follow-up if you'd like.


Generated by Claude Code


/// Canonical namespace name for MedCare / Healthcare. Mirrors
/// `HealthcarePort::NAMESPACE` so existing consumers that imported the
/// constant from this module keep building.
pub const NAMESPACE: &str = HealthcarePort::NAMESPACE;

#[cfg(test)]
mod tests {
//! Co-located unit tests for the migrated alias — constructor
//! success/failure, contract methods, codebook resolution, fallback
//! to registry. Mirrors `openproject_bridge::tests`; only the
//! `MedcareBridge` ident (now `UnifiedBridge<HealthcarePort>`) and the
//! Healthcare fixtures differ.

impl MedcareBridge {
pub fn new(registry: Arc<OntologyRegistry>) -> Result<Self> {
let g_lock = registry
.namespace_id(NAMESPACE)
.ok_or_else(|| Error::UnknownNamespace(NAMESPACE.to_string()))?;
Ok(Self { registry, g_lock })
use super::*;
use crate::bridge::{BridgeError, NamespaceBridge};
use crate::error::Error;
use crate::namespace::NamespaceId;
use crate::namespace_registry::NamespaceRegistry;
use crate::registry::OntologyRegistry;
use ogar_vocab::class_ids;
// PortSpec needed in scope for `HealthcarePort::aliases()`.
use ogar_vocab::ports::PortSpec;
use std::fs;
use std::sync::Arc;

fn registry_with_healthcare() -> Arc<OntologyRegistry> {
let ttl = r#"
@prefix ogit: <http://www.purl.org/ogit/> .
@prefix ogit.Healthcare: <http://www.purl.org/ogit/Healthcare/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

ogit.Healthcare:Patient
a rdfs:Class;
rdfs:subClassOf ogit:Entity;
rdfs:label "Patient";
ogit:scope "NTO";
ogit:parent ogit:Node;
ogit:mandatory-attributes ( ogit:id );
ogit:optional-attributes ( ) ;
.
"#;
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("Healthcare")).unwrap();
fs::write(tmp.path().join("Healthcare").join("ents.ttl"), ttl).unwrap();
let registry = Arc::new(OntologyRegistry::new_in_memory());
registry
.hydrate_once_sync(tmp.path(), &["Healthcare"])
.unwrap();
std::mem::forget(tmp);
registry
}
Comment thread
AdaWorldAPI marked this conversation as resolved.
}

impl NamespaceBridge for MedcareBridge {
fn bridge_id(&self) -> &'static str {
"medcare"
#[test]
fn new_succeeds_when_namespace_registered() {
let registry = registry_with_healthcare();
let bridge = MedcareBridge::new(registry).unwrap();
assert_ne!(bridge.g_lock(), NamespaceId::UNKNOWN);
}
fn registry(&self) -> &OntologyRegistry {
&self.registry

#[test]
fn new_returns_unknown_namespace_when_not_registered() {
let registry = Arc::new(OntologyRegistry::new_in_memory());
// `unwrap_err()` would require `MedcareBridge: Debug`, which the
// underlying `UnifiedBridge<P>` intentionally doesn't impl.
match MedcareBridge::new(registry) {
Ok(_) => panic!("expected UnknownNamespace, got Ok(_)"),
Err(Error::UnknownNamespace(name)) => assert_eq!(name, "Healthcare"),
Err(other) => panic!("expected UnknownNamespace, got {other:?}"),
}
}
fn g_lock(&self) -> NamespaceId {
self.g_lock

#[test]
fn bridge_id_is_lowercase_medcare() {
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
assert_eq!(bridge.bridge_id(), "medcare");
}

#[test]
fn g_lock_matches_registry_namespace_id() {
let registry = registry_with_healthcare();
let expected = registry.namespace_id(NAMESPACE).unwrap();
let bridge = MedcareBridge::new(registry).unwrap();
assert_eq!(bridge.g_lock(), expected);
}

#[test]
fn entity_resolves_patient_to_canonical_class_id() {
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
let entity = bridge.entity("Patient").unwrap();
assert_eq!(entity.schema_ptr.entity_type_id(), class_ids::PATIENT);
assert_eq!(entity.schema_ptr.entity_type_id(), 0x0901);
}

#[test]
fn entity_synthesised_schema_ptr_stamps_seeded_context_id() {
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
let entity = bridge.entity("Patient").unwrap();
let expected = NamespaceRegistry::seed_context_id("Healthcare").unwrap();
assert_eq!(entity.schema_ptr.ontology_context_id(), expected);
assert_eq!(entity.schema_ptr.ontology_context_id(), 2);
}

#[test]
fn entity_for_each_codebook_entry_returns_its_canonical_class_id() {
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
for &(public_name, expected_id) in HealthcarePort::aliases() {
let entity = bridge.entity(public_name).unwrap_or_else(|e| {
panic!("codebook entry `{public_name}` failed to resolve: {e:?}")
});
assert_eq!(
entity.schema_ptr.entity_type_id(),
expected_id,
"codebook entry `{public_name}` should resolve to 0x{expected_id:04X}",
);
}
}
}

impl BridgeFromRegistry for MedcareBridge {
fn from_registry(registry: Arc<OntologyRegistry>) -> Result<Self> {
Self::new(registry)
#[test]
fn entity_for_non_codebook_name_falls_back_to_registry_lookup() {
let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap();
let err = bridge.entity("NotAConcept").unwrap_err();
match err {
BridgeError::NotInScope { public_name, .. } => {
assert_eq!(public_name, "NotAConcept")
}
other => panic!("expected NotInScope, got {other:?}"),
}
}
}
25 changes: 15 additions & 10 deletions crates/lance-graph-ontology/src/bridges/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
//! (`NAMESPACE` / `BRIDGE_ID` / public-name → class_id aliases) from
//! [`ogar_vocab::ports::PortSpec`]. Adding a port is `impl PortSpec
//! for FooPort {…}` in OGAR — no bridge boilerplate here.
//! - The **legacy per-tenant bridges** ([`WoaBridge`], [`MedcareBridge`],
//! [`SpearBridge`], [`SharePointBridge`], [`OgitBridge`]) keep their
//! bespoke struct shape for now. They predate OGAR's codebook and
//! don't yet have a `PortSpec` impl in `ogar-vocab::ports`. When the
//! WorkOrder / Healthcare / EmailCorrespondance / SharePoint
//! namespaces get promoted into the codebook, these collapse the
//! same way OpenProject and Redmine just did.
//! - The **legacy per-tenant bridges** ([`WoaBridge`], [`SpearBridge`],
//! [`SharePointBridge`], [`OgitBridge`]) keep their bespoke struct
//! shape for now. They predate OGAR's codebook and don't yet have a
//! `PortSpec` impl in `ogar-vocab::ports`. When the WorkOrder /
//! EmailCorrespondance / SharePoint namespaces get promoted into the
//! codebook, these collapse the same way OpenProject, Redmine, and
//! MedCare already did.
//!
//! # Project-management ports (`UnifiedBridge<P>` aliases)
//! # OGAR-driven ports (`UnifiedBridge<P>` aliases)
//!
//! - [`OpenProjectBridge`]: `UnifiedBridge<ogar_vocab::ports::OpenProjectPort>`
//! — locks to the `OpenProject` namespace. `WorkPackage` / `TimeEntry`
Expand All @@ -27,14 +27,19 @@
//! etc. resolve to the SAME OGAR canonical class_ids as the
//! OpenProject equivalents, so cross-fork convergence is the default
//! not the exception.
//! - [`MedcareBridge`]: `UnifiedBridge<ogar_vocab::ports::HealthcarePort>`
//! — locks to the `Healthcare` namespace. `Patient` / `Diagnosis` /
//! `LabValue` / `Medication` / `Treatment` / `Visit` / `VitalSign`
//! resolve to the `0x09XX` Health codebook (Northstar T9). Single-
//! tenant today; a future FMA / SNOMED curator converges on the same
//! ids.
//!
//! # Per-tenant bridges (legacy struct shape)
//!
//! - [`OgitBridge`]: pass-through bridge for tools that already speak raw
//! OGIT URIs. `bridge_id = "ogit"`. Locks to whatever namespace its
//! constructor is called with.
//! - [`WoaBridge`]: locks to the `WorkOrder` namespace.
//! - [`MedcareBridge`]: locks to the `Healthcare` namespace.
//! - [`SpearBridge`]: locks to the `EmailCorrespondance` namespace.
//! - [`SharePointBridge`]: locks to the `SharePoint` namespace.
//!
Expand All @@ -53,7 +58,7 @@ mod sharepoint_bridge;
mod spear_bridge;
mod woa_bridge;

pub use medcare_bridge::MedcareBridge;
pub use medcare_bridge::{HealthcarePort, MedcareBridge};
pub use ogit_bridge::OgitBridge;
pub use openproject_bridge::{OpenProjectBridge, OpenProjectPort};
pub use redmine_bridge::{RedmineBridge, RedminePort};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,12 @@ fn openproject_bridge_construction_fails_when_namespace_missing() {
// Error::UnknownNamespace.
let registry = Arc::new(OntologyRegistry::new_in_memory());
let result = OpenProjectBridge::new(registry);
// NB: `OpenProjectBridge` is `UnifiedBridge<OpenProjectPort>`, which
// intentionally does not implement `Debug`, so we assert on `is_err()`
// without formatting the `Ok` value (pre-#570 the bridge was a Debug
// struct and this message interpolated `{result:?}`).
assert!(
result.is_err(),
"expected UnknownNamespace error, got {result:?}",
"expected UnknownNamespace error from constructing over an empty registry",
);
}
Loading
Loading