From 8b555e8cfbe703a76ada18015a157161fc5236f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:16:33 +0000 Subject: [PATCH] vocab: append-only attributes, screen-field mints, specialist_referral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field minted after a class's edges were fixed is flagged `after_edges` and projected after them, so positions already pinned by a consumer's FieldMask (diagnosis: patient=10, encounter=11) do not move. `Class::field_basis` spells that order on the vocabulary side and a test checks it equals the class view's projection for every promoted class. Mints, each a column of a harvested MedCare screen: - diagnosis: recorded_on - lab_value: recorded_at, order_id - treatment: ended_at, session_count - examination: form_kind, note - document: name, uploaded_at, keywords, note - specialist_referral (0x090D, Facharztüberweisung): patient and referred_to edges; start_date, end_date, status, note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-class-view/src/lib.rs | 53 ++++++++++- crates/ogar-vocab/src/lib.rs | 141 +++++++++++++++++++++++++++--- crates/ogar-vocab/src/ports.rs | 2 +- 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs index cfda05bd..d7232db8 100644 --- a/crates/ogar-class-view/src/lib.rs +++ b/crates/ogar-class-view/src/lib.rs @@ -171,6 +171,7 @@ use ogar_vocab::{ recoder, rom_image, skeleton, + specialist_referral, tax_policy, textline, treatment, @@ -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()), @@ -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 @@ -350,12 +357,15 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> { fn lift_object_view(class: &Class) -> ObjectView { let mut fields: Vec = 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 @@ -694,6 +704,7 @@ mod tests { "examination", "practitioner", "external_practice", + "specialist_referral", ] { let id = canonical_concept_id(concept).unwrap(); let view = v @@ -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 + ); + } } } diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index cc5b80b3..5fef61d7 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -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, + /// 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 @@ -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 @@ -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 @@ -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 { CODEBOOK @@ -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) ── @@ -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), @@ -3436,6 +3485,7 @@ pub fn all_promoted_classes() -> Vec { examination(), practitioner(), external_practice(), + specialist_referral(), // 0x0AXX — anatomy arm (FMA reference kinds), in class_ids::ALL order. anatomical_structure(), skeleton(), @@ -4561,6 +4611,12 @@ pub fn document() -> Class { attr("mime", "String"), attr("pages", "Vec"), attr("fields", "Vec"), + // 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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 @@ -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 } @@ -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")); @@ -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)); @@ -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!( @@ -6477,6 +6591,7 @@ mod tests { "examination", "practitioner", "external_practice", + "specialist_referral", ], "Health domain set drift — re-sync the consumer coverage gate", ); @@ -6484,8 +6599,8 @@ mod tests { 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 diff --git a/crates/ogar-vocab/src/ports.rs b/crates/ogar-vocab/src/ports.rs index 9686e7b6..5ec7e2e3 100644 --- a/crates/ogar-vocab/src/ports.rs +++ b/crates/ogar-vocab/src/ports.rs @@ -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