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
53 changes: 49 additions & 4 deletions crates/ogar-class-view/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ use ogar_vocab::{
recoder,
rom_image,
skeleton,
specialist_referral,
tax_policy,
textline,
treatment,
Expand Down Expand Up @@ -289,6 +290,7 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> {
("examination", examination()),
("practitioner", practitioner()),
("external_practice", external_practice()),
("specialist_referral", specialist_referral()),
// ── 0x0AXX — anatomy (FMA reference kinds) ──
("anatomical_structure", anatomical_structure()),
("skeleton", skeleton()),
Expand Down Expand Up @@ -341,6 +343,11 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> {
/// Field order — **N3 stable, append-only**:
/// 1. Typed attributes, in declaration order on the class fn.
/// 2. Family-edge associations, in declaration order on the class fn.
/// 3. Attributes minted after the edges ([`AttributeOptions::after_edges`]),
/// in declaration order. This is what keeps the basis append-only: a new
/// attribute never moves an edge.
///
/// [`AttributeOptions::after_edges`]: ogar_vocab::AttributeOptions::after_edges
///
/// `predicate_iri` is the slot's name as written (snake_case); `label`
/// mirrors it (display localisation happens downstream). The
Expand All @@ -350,12 +357,15 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> {
fn lift_object_view(class: &Class) -> ObjectView {
let mut fields: Vec<FieldRef> =
Vec::with_capacity(class.attributes.len() + class.associations.len());
for attr in &class.attributes {
for attr in class.attributes.iter().filter(|a| !a.options.after_edges) {
fields.push(FieldRef::new(attr.name.clone(), attr.name.clone()));
}
for assoc in &class.associations {
fields.push(FieldRef::new(assoc.name.clone(), assoc.name.clone()));
}
for attr in class.attributes.iter().filter(|a| a.options.after_edges) {
fields.push(FieldRef::new(attr.name.clone(), attr.name.clone()));
}
let mut view = ObjectView::new(DisplayTemplate::Detail, fields);
// Convention: primary_label is the first typed attribute named "name"
// when present, falling back to None (the contract treats None as
Expand Down Expand Up @@ -694,6 +704,7 @@ mod tests {
"examination",
"practitioner",
"external_practice",
"specialist_referral",
] {
let id = canonical_concept_id(concept).unwrap();
let view = v
Expand All @@ -704,15 +715,49 @@ mod tests {
}

#[test]
fn diagnosis_field_basis_is_ten_attributes_then_two_edges() {
fn diagnosis_field_basis_is_ten_attributes_two_edges_then_appended() {
// The 0x0902 worked example, projected: attributes first (icd_code
// leads), then the two family edges, in declaration order.
// leads), then the two family edges, then the attributes minted
// after the edges were fixed. Positions 0..=11 predate `recorded_on`
// and must not move: a FieldMask pinned against them still selects
// the same fields.
let v = OgarClassView::new();
let id = canonical_concept_id("diagnosis").unwrap();
let fields = v.fields(id);
assert_eq!(fields.len(), 12); // 10 attributes + 2 edges
assert_eq!(fields.len(), 13); // 10 attributes + 2 edges + 1 appended
assert_eq!(fields[0].predicate_iri, "icd_code");
assert_eq!(fields[10].predicate_iri, "patient");
assert_eq!(fields[11].predicate_iri, "encounter");
assert_eq!(fields[12].predicate_iri, "recorded_on");
}

/// `Class::field_basis` is the vocabulary-side spelling of the order the
/// class view projects. A consumer that pins positions from the vocabulary
/// (MedCare's RBAC field list does) gets the class view's positions.
#[test]
fn field_basis_matches_the_projected_order_for_every_class() {
let v = OgarClassView::new();
let classes = ogar_vocab::all_promoted_classes();
assert!(classes.len() > 50, "class list unexpectedly small");
for class in &classes {
let Some(id) = class
.canonical_concept
.as_deref()
.and_then(canonical_concept_id)
else {
continue;
};
let projected: Vec<&str> = v
.fields(id)
.iter()
.map(|f| f.predicate_iri.as_str())
.collect();
assert_eq!(
projected,
class.field_basis(),
"{:?} basis drifted",
class.canonical_concept
);
}
}
}
141 changes: 128 additions & 13 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,25 @@ pub struct AttributeOptions {
/// `currency_field='currency_id'` — Monetary field's currency
/// linkage. Required for `Monetary` type, ignored otherwise.
pub currency_field: Option<String>,
/// The attribute was minted after the class's family edges, so its
/// basis position comes after them. The basis is append-only: an
/// attribute added to a class that already has edges must set this,
/// or every edge position (and every field mask that names one) would
/// shift by one. Attributes without it keep their place before the
/// edges.
pub after_edges: bool,
}

impl Attribute {
/// An attribute appended to an existing class: typed, and placed after
/// the family edges in the basis (see [`AttributeOptions::after_edges`]).
#[must_use]
pub fn appended(name: &str, type_name: &str) -> Self {
let mut a = Attribute::new(name);
a.type_name = Some(type_name.to_string());
a.options.after_edges = true;
a
}
}

/// A `scope :name, -> { body }` definition. `body_source` is opaque
Expand Down Expand Up @@ -1062,6 +1081,28 @@ impl Class {
}
}

/// The field basis: the field names in position order, the order the
/// `FieldMask` bits index. Attributes first, then family edges, then
/// attributes minted after the edges ([`AttributeOptions::after_edges`]).
///
/// This is the only definition of the order; a consumer that needs a
/// field's position reads it here instead of rebuilding it.
#[must_use]
pub fn field_basis(&self) -> Vec<&str> {
self.attributes
.iter()
.filter(|a| !a.options.after_edges)
.map(|a| a.name.as_str())
.chain(self.associations.iter().map(|e| e.name.as_str()))
.chain(
self.attributes
.iter()
.filter(|a| a.options.after_edges)
.map(|a| a.name.as_str()),
)
.collect()
}

/// Resolve this class's binary [OGAR codebook] identity — the `u16`
/// canonical id derived from its stored `canonical_concept`. Returns
/// `None` when the class has no canonical concept set (a producer
Expand Down Expand Up @@ -1333,6 +1374,7 @@ const CODEBOOK: &[(&str, u16)] = &[
("examination", 0x090A),
("practitioner", 0x090B),
("external_practice", 0x090C),
("specialist_referral", 0x090D),
// ── 0x0AXX — Anatomy domain (FMA reference ontology) ──
// The public anatomical reference frame consumed by the splat-native
// ultrasound arc (`docs/SPLAT-NATIVE-CUSTOMER.md` §6 litmus) and the FMA
Expand Down Expand Up @@ -1852,7 +1894,7 @@ pub fn canonical_concept_domain(id: u16) -> ConceptDomain {
/// .map(|(name, _id)| name)
/// .collect();
/// assert!(health.contains(&"patient"));
/// assert_eq!(health.len(), 12); // 7 OGIT Healthcare entities + 5 harvest-derived mints
/// assert_eq!(health.len(), 13); // 7 OGIT Healthcare entities + 6 harvest-derived mints
/// ```
pub fn concepts_in_domain(domain: ConceptDomain) -> impl Iterator<Item = (&'static str, u16)> {
CODEBOOK
Expand Down Expand Up @@ -2234,6 +2276,12 @@ pub mod class_ids {
/// than method-only; no clean OGIT entity — no port alias until one
/// exists.
pub const EXTERNAL_PRACTICE: u16 = 0x090C;
/// `specialist_referral` (`0x090D`) — a referral of a patient to a
/// specialist (German practice software: Facharztüberweisung). Lives in
/// the patient file, names the practitioner referred to, and runs from
/// a start date until it is closed. No curated OGIT entity — no port
/// alias until one exists.
pub const SPECIALIST_REFERRAL: u16 = 0x090D;

// ── 0x0AXX — Anatomy domain (FMA reference ontology) ──

Expand Down Expand Up @@ -2486,6 +2534,7 @@ pub mod class_ids {
("examination", EXAMINATION),
("practitioner", PRACTITIONER),
("external_practice", EXTERNAL_PRACTICE),
("specialist_referral", SPECIALIST_REFERRAL),
// 0x0AXX — anatomy (FMA reference ontology)
("anatomical_structure", ANATOMICAL_STRUCTURE),
("skeleton", SKELETON),
Expand Down Expand Up @@ -3436,6 +3485,7 @@ pub fn all_promoted_classes() -> Vec<Class> {
examination(),
practitioner(),
external_practice(),
specialist_referral(),
// 0x0AXX — anatomy arm (FMA reference kinds), in class_ids::ALL order.
anatomical_structure(),
skeleton(),
Expand Down Expand Up @@ -4561,6 +4611,12 @@ pub fn document() -> Class {
attr("mime", "String"),
attr("pages", "Vec<DocPage>"),
attr("fields", "Vec<TypedField>"),
// Minted later (append-only basis): the stored-file view of a
// document, as a document store lists it.
Attribute::appended("name", "string"),
Attribute::appended("uploaded_at", "datetime"),
Attribute::appended("keywords", "string"),
Attribute::appended("note", "text"),
];
c
}
Expand Down Expand Up @@ -4650,6 +4706,9 @@ pub fn diagnosis() -> Class {
resolved_date,
is_primary,
note,
// Minted after the edges (append-only basis): the date the finding
// was recorded, which `onset_date` is not.
Attribute::appended("recorded_on", "date"),
];
c
}
Expand Down Expand Up @@ -4684,6 +4743,10 @@ pub fn lab_value() -> Class {
reference_range,
abnormal_flag,
collected_at,
// Minted after the edges (append-only basis). `recorded_at` is when
// the result was entered, not when the sample was collected.
Attribute::appended("recorded_at", "datetime"),
Attribute::appended("order_id", "string"),
];
c
}
Expand Down Expand Up @@ -4733,7 +4796,16 @@ pub fn treatment() -> Class {
performed_at.type_name = Some("datetime".to_string());
let mut outcome = Attribute::new("outcome");
outcome.type_name = Some("string".to_string());
c.attributes = vec![code, description, performed_at, outcome];
c.attributes = vec![
code,
description,
performed_at,
outcome,
// Minted after the edges (append-only basis): a treatment that runs
// over a period, and how many sessions it covers.
Attribute::appended("ended_at", "datetime"),
Attribute::appended("session_count", "integer"),
];
c
}

Expand Down Expand Up @@ -4830,6 +4902,33 @@ pub fn investigation() -> Class {
c
}

/// SpecialistReferral — a referral of a patient to a specialist
/// (`0x090D`). The referral runs from a start date until it is closed, is
/// either open or done, and names the practitioner referred to.
#[must_use]
pub fn specialist_referral() -> Class {
let mut c = Class::new("SpecialistReferral");
c.language = Language::Unknown;
c.canonical_concept = Some("specialist_referral".to_string());
c.description = Some("A referral of a patient to a specialist".to_string());
c.associations = vec![
family_edge("patient", "Patient"),
family_edge("referred_to", "Practitioner"),
];
let typed = |name: &str, ty: &str| {
let mut a = Attribute::new(name);
a.type_name = Some(ty.to_string());
a
};
c.attributes = vec![
typed("start_date", "date"),
typed("end_date", "date"),
typed("status", "string"),
typed("note", "text"),
];
c
}

/// Examination — a performed examination record (`0x090A`), distinct
/// from [`investigation`] (performed exam vs ordered workup). Parallel
/// grounding: OGIT `Healthcare:Assessment` (upstream 2026-07-06, not
Expand All @@ -4843,7 +4942,12 @@ pub fn examination() -> Class {
c.associations = vec![family_edge("patient", "Patient")];
let mut performed_at = Attribute::new("performed_at");
performed_at.type_name = Some("datetime".to_string());
c.attributes = vec![performed_at];
c.attributes = vec![
performed_at,
// Minted after the edges (append-only basis).
Attribute::appended("form_kind", "string"),
Attribute::appended("note", "text"),
];
c
}

Expand Down Expand Up @@ -6257,6 +6361,7 @@ mod tests {
"examination",
"practitioner",
"external_practice",
"specialist_referral",
] {
let id = canonical_concept_id(health_concept)
.unwrap_or_else(|| panic!("{health_concept} missing from codebook"));
Expand Down Expand Up @@ -6390,6 +6495,7 @@ mod tests {
(examination, "examination", 0x090A),
(practitioner, "practitioner", 0x090B),
(external_practice, "external_practice", 0x090C),
(specialist_referral, "specialist_referral", 0x090D),
] {
let c = builder();
assert_eq!(c.canonical_concept.as_deref(), Some(concept));
Expand All @@ -6409,17 +6515,25 @@ mod tests {
// via the production lift, never by hand. Growing one of these
// sets is a deliberate act: bring the harvest evidence and update
// this pin in the same change.
for (c, expected_edges) in [
(anamnesis(), 1usize),
(investigation(), 1),
(examination(), 1),
(practitioner(), 0),
(external_practice(), 0),
//
// Grown with evidence (2026-10-15): `examination` gained `form_kind`
// and `note`, and `specialist_referral` was minted with four
// attributes. Each is a column of a harvested MedCare screen
// (FormularMaster: kind label and free text; Facharztüberweisung:
// start, stop, done flag, notice), appended after the edges so no
// earlier position moves.
for (c, expected_attrs, expected_edges) in [
(anamnesis(), 1usize, 1usize),
(investigation(), 1, 1),
(examination(), 3, 1),
(practitioner(), 1, 0),
(external_practice(), 1, 0),
(specialist_referral(), 4, 2),
] {
assert_eq!(
c.attributes.len(),
1,
"{}: harvest-derived mint grew past its name-level schema",
expected_attrs,
"{}: harvest-derived mint grew past its evidenced schema",
c.name,
);
assert_eq!(
Expand Down Expand Up @@ -6477,15 +6591,16 @@ mod tests {
"examination",
"practitioner",
"external_practice",
"specialist_referral",
],
"Health domain set drift — re-sync the consumer coverage gate",
);
// Every yielded id really is in-domain.
for (_, id) in concepts_in_domain(ConceptDomain::Health) {
assert_eq!(canonical_concept_domain(id), ConceptDomain::Health);
}
// Counts line up with the codebook blocks (7 OGIT + 5 harvest mints).
assert_eq!(concepts_in_domain(ConceptDomain::Health).count(), 12);
// Counts line up with the codebook blocks (7 OGIT + 6 harvest mints).
assert_eq!(concepts_in_domain(ConceptDomain::Health).count(), 13);
// 0x08XX OCR: nine container KINDS (unicharset/recoder/charset/
// network_layer + the PDF→text plan mints textline/blob/page_layout/
// page_image/ocr_renderer) plus the generic `document` (0x080B — the
Expand Down
2 changes: 1 addition & 1 deletion crates/ogar-vocab/src/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -908,7 +908,7 @@ mod tests {
// Patient / Diagnosis / LabValue / Medication / Treatment / Visit
// / VitalSign — the 7 OGIT `NTO/Healthcare/entities/` classes this
// port was lifted from. Deliberately NOT the Health-domain codebook
// cardinality (12): the 0x0908..0x090C harvest-derived mints carry
// cardinality (13): the 0x0908..0x090D harvest-derived mints carry
// no OGIT entity and therefore no port alias. (0x090C
// external_practice has a mechanical sql_mirror TTL stub but no
// curated OGIT NTO/Healthcare entity — the port mirrors the curated
Expand Down
Loading